Skip to main content
Technology & EngineeringReact Patterns251 lines

Optimistic Updates

Optimistic update patterns for instant UI feedback with server reconciliation and rollback in React

Quick Summary18 lines
You are an expert in Optimistic Update patterns for building React applications.

## Key Points

- **Immediate State Mutation**: Update the UI state as soon as the user acts, before the network round-trip completes.
- **Background Server Request**: Fire the actual mutation to the server in parallel with the UI update.
- **Rollback on Failure**: If the server rejects the mutation, revert the UI to the previous state and notify the user.
- **Reconciliation on Success**: When the server responds, replace the optimistic data with the canonical server data.
- **Idempotency**: Mutations should be idempotent so retries after transient failures do not cause duplicates.
- Always snapshot the previous state before applying the optimistic update so you can roll back cleanly.
- Show a subtle visual indicator (lower opacity, spinner icon) for items in an optimistic/pending state.
- After server confirmation, reconcile with the actual server response rather than keeping the optimistic data — the server may normalize or transform values.
- Debounce rapid mutations (e.g., toggling the same button quickly) to avoid race conditions.
- Make mutations idempotent on the server so that retries are safe.
- **Missing rollback**: Forgetting to restore previous state on error leaves the UI in a state that disagrees with the server.
- **Race conditions**: If a user triggers multiple mutations on the same resource, responses may arrive out of order. Use sequence numbers or cancel stale requests.
skilldb get react-patterns-skills/Optimistic UpdatesFull skill: 251 lines
Paste into your CLAUDE.md or agent config

Optimistic Updates — React Patterns

You are an expert in Optimistic Update patterns for building React applications.

Overview

Optimistic updates immediately reflect a user's action in the UI before the server confirms it. The client assumes the mutation will succeed, updates local state instantly, and then reconciles once the server responds — rolling back if the request fails. This pattern eliminates perceived latency for mutations like toggling likes, adding comments, reordering lists, and submitting forms.

Core Philosophy

Optimistic updates exist because perceived performance matters more than actual performance. A 200ms network round-trip is fast by engineering standards, but it feels sluggish to a user who just tapped a button and is waiting for visual confirmation. By updating the UI immediately and reconciling with the server afterward, you make the interface feel instant, even though the underlying operation takes the same amount of time.

This pattern requires a deliberate shift in how you think about truth. In a traditional request-response model, the server is the single source of truth and the UI waits for confirmation before changing. With optimistic updates, the client temporarily becomes the source of truth, and the server confirms or corrects after the fact. This means your code must handle three states for every mutation: the optimistic state (what the user expects to see), the confirmed state (what the server says is real), and the rollback state (what to show if the server disagrees).

The tradeoff is complexity for responsiveness. Every optimistic update must have a rollback path, a reconciliation path, and a strategy for handling concurrent mutations on the same resource. If you are not prepared to implement all three, the pattern will create more bugs than it solves. Reserve optimistic updates for mutations where the success rate is very high (above 99%), the user expectation of instant feedback is strong, and the rollback experience is not disorienting.

Core Concepts

  • Immediate State Mutation: Update the UI state as soon as the user acts, before the network round-trip completes.
  • Background Server Request: Fire the actual mutation to the server in parallel with the UI update.
  • Rollback on Failure: If the server rejects the mutation, revert the UI to the previous state and notify the user.
  • Reconciliation on Success: When the server responds, replace the optimistic data with the canonical server data.
  • Idempotency: Mutations should be idempotent so retries after transient failures do not cause duplicates.

Implementation Patterns

Basic Optimistic Toggle with useState

import { useState } from "react";

function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) {
  const [liked, setLiked] = useState(initialLiked);
  const [pending, setPending] = useState(false);

  async function handleToggle() {
    const previousLiked = liked;
    setLiked(!liked); // Optimistic update
    setPending(true);

    try {
      const res = await fetch(`/api/posts/${postId}/like`, {
        method: liked ? "DELETE" : "POST",
      });
      if (!res.ok) throw new Error("Failed");
    } catch {
      setLiked(previousLiked); // Rollback
    } finally {
      setPending(false);
    }
  }

  return (
    <button onClick={handleToggle} disabled={pending} aria-pressed={liked}>
      {liked ? "Unlike" : "Like"}
    </button>
  );
}

React Query Optimistic Mutation

import { useMutation, useQueryClient } from "@tanstack/react-query";

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

function useTodoToggle() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo: Todo) =>
      fetch(`/api/todos/${todo.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: !todo.completed }),
      }).then((r) => {
        if (!r.ok) throw new Error("Failed to update");
        return r.json() as Promise<Todo>;
      }),

    onMutate: async (todo) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      // Snapshot previous value
      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);

      // Optimistically update the cache
      queryClient.setQueryData<Todo[]>(["todos"], (old) =>
        old?.map((t) => (t.id === todo.id ? { ...t, completed: !t.completed } : t))
      );

      return { previousTodos };
    },

    onError: (_err, _todo, context) => {
      // Rollback to snapshot
      if (context?.previousTodos) {
        queryClient.setQueryData(["todos"], context.previousTodos);
      }
    },

    onSettled: () => {
      // Refetch to ensure server state
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

React 19 useOptimistic Hook

import { useOptimistic, useTransition } from "react";

interface Message {
  id: string;
  text: string;
  sending?: boolean;
}

function Chat({ messages: serverMessages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[], string>(
    serverMessages,
    (state, newText) => [
      ...state,
      { id: `temp-${Date.now()}`, text: newText, sending: true },
    ]
  );
  const [isPending, startTransition] = useTransition();

  async function sendMessage(formData: FormData) {
    const text = formData.get("message") as string;
    startTransition(async () => {
      addOptimisticMessage(text);
      await fetch("/api/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });
    });
  }

  return (
    <div>
      <ul>
        {optimisticMessages.map((msg) => (
          <li key={msg.id} style={{ opacity: msg.sending ? 0.6 : 1 }}>
            {msg.text}
          </li>
        ))}
      </ul>
      <form action={sendMessage}>
        <input name="message" required />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Optimistic List Reordering

import { useState } from "react";

interface Item {
  id: string;
  position: number;
  title: string;
}

function SortableList({ initialItems }: { initialItems: Item[] }) {
  const [items, setItems] = useState(initialItems);

  async function handleReorder(dragId: string, targetIndex: number) {
    const previousItems = [...items];

    // Optimistically reorder
    const dragIndex = items.findIndex((i) => i.id === dragId);
    const reordered = [...items];
    const [moved] = reordered.splice(dragIndex, 1);
    reordered.splice(targetIndex, 0, moved);
    const withPositions = reordered.map((item, i) => ({ ...item, position: i }));
    setItems(withPositions);

    try {
      const res = await fetch("/api/items/reorder", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          itemId: dragId,
          newPosition: targetIndex,
        }),
      });
      if (!res.ok) throw new Error("Reorder failed");
    } catch {
      setItems(previousItems); // Rollback
    }
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={item.id} draggable onDrop={() => handleReorder(item.id, index)}>
          {item.title}
        </li>
      ))}
    </ul>
  );
}

Best Practices

  • Always snapshot the previous state before applying the optimistic update so you can roll back cleanly.
  • Show a subtle visual indicator (lower opacity, spinner icon) for items in an optimistic/pending state.
  • After server confirmation, reconcile with the actual server response rather than keeping the optimistic data — the server may normalize or transform values.
  • Debounce rapid mutations (e.g., toggling the same button quickly) to avoid race conditions.
  • Make mutations idempotent on the server so that retries are safe.

Common Pitfalls

  • Missing rollback: Forgetting to restore previous state on error leaves the UI in a state that disagrees with the server.
  • Race conditions: If a user triggers multiple mutations on the same resource, responses may arrive out of order. Use sequence numbers or cancel stale requests.
  • Stale closures: The rollback function must capture the state as it was before the optimistic update, not the current state at the time of rollback.
  • No error feedback: Silently rolling back without telling the user is confusing. Show a toast or inline error message.
  • Optimistic deletes without undo: Optimistically removing an item with no undo mechanism frustrates users when the deletion was accidental. Provide a brief undo window or confirmation.

Anti-Patterns

  • Optimistic updates for unreliable operations: Applying optimistic UI to mutations that frequently fail (payment processing, third-party API calls, complex validations). When rollbacks happen often, users lose trust in the interface because it repeatedly shows success before reverting.

  • Snapshot-less rollback: Attempting to roll back by reversing the mutation (e.g., decrementing a counter after an increment fails) instead of restoring a snapshot of the previous state. Reverse operations are error-prone when concurrent mutations overlap.

  • Ignoring concurrent mutations: Allowing multiple optimistic updates on the same resource without serialization or conflict detection. Two rapid toggles on the same item can produce a state where the UI shows the opposite of what the server accepted.

  • Keeping optimistic data after server confirmation: Trusting the optimistic value permanently instead of replacing it with the server's canonical response. The server may normalize, transform, or enrich the data (e.g., adding timestamps, computed fields), and the UI should reflect the real result.

  • No user feedback on rollback: Silently reverting the UI to its previous state after a server failure. The user performed an action, saw it succeed, and then it vanished. Without a toast, inline error, or other notification, this feels like a bug rather than a recoverable failure.

Install this skill directly: skilldb add react-patterns-skills

Get CLI access →