React Optimistic UI Updates: A Practical, Fail‑Safe Tutorial

Build fast, resilient optimistic UI updates in React. Learn patterns, rollback, and examples using plain hooks and TanStack Query.

ASOasis
11 min read
React Optimistic UI Updates: A Practical, Fail‑Safe Tutorial

Image used for representation purposes only.

What is an optimistic UI (and why it matters)

Optimistic UI updates render the result of a user action immediately—before the server confirms it—then reconcile with the real response. When the server succeeds, the UI is already correct; when it fails, you roll back or fix up the view.

Benefits:

  • Perceived speed: interactions feel instant, even on slow or mobile networks.
  • Flow preservation: users aren’t blocked waiting for spinners to finish.
  • Higher completion: fewer abandoned actions due to latency frustration.

Trade‑offs:

  • Complexity: you must manage rollbacks, deduplication, and race conditions.
  • Consistency: the UI is temporarily ahead of the server; you need reliable reconciliation.

This tutorial walks through practical, fail‑safe patterns for optimistic updates in React—first with plain hooks, then with a data‑fetching library—plus production tips for errors, pagination, offline support, and testing.

When to (and not to) use optimistic updates

Use optimistic updates when:

  • The action is frequent and latency-sensitive (toggling a like, adding a todo, reordering lists).
  • The server success rate is high and validation is predictable.
  • Conflicts are either rare or easily resolved on the client.

Avoid or be conservative when:

  • Server-side validation regularly rejects the action (e.g., limited inventory).
  • Conflicts are common and resolution is non-trivial (financial transactions, double-bookings).
  • Security or authorization is uncertain (never assume success where permissions may fail).

Mental model: predict, render, reconcile

  1. Predict: Produce a plausible next state locally. Assign stable temporary IDs for new entities.
  2. Render: Update the UI immediately.
  3. Reconcile: When the server responds, confirm, replace temp IDs with real ones, or roll back and notify the user.

Key invariants:

  • Every optimistic change must be revertible.
  • Server is the source of truth; the cache/UI is eventually consistent with it.
  • Responses may arrive out of order—guard against stale overwrites.

Baseline example with plain React hooks

We’ll implement a Todo list supporting create, toggle, and delete with optimistic updates and rollbacks.

Domain model

// types.ts
export type Todo = {
  id: string;           // real or temporary id
  title: string;
  completed: boolean;
  optimistic?: boolean; // mark items created/changed optimistically
};

Simple API helpers

// api.ts
export async function createTodo(title: string) {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title })
  });
  if (!res.ok) throw new Error('Failed to create');
  return (await res.json()) as { id: string; title: string; completed: boolean };
}

export async function toggleTodo(id: string, completed: boolean) {
  const res = await fetch(`/api/todos/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ completed })
  });
  if (!res.ok) throw new Error('Failed to toggle');
  return (await res.json()) as { id: string; completed: boolean };
}

export async function deleteTodo(id: string) {
  const res = await fetch(`/api/todos/${id}`, { method: 'DELETE' });
  if (!res.ok) throw new Error('Failed to delete');
}

useOptimisticTodos hook

This hook applies the predict → render → reconcile pattern and guards against stale responses.

// useOptimisticTodos.tsx
import { useRef, useState } from 'react';
import { createTodo, toggleTodo, deleteTodo } from './api';
import type { Todo } from './types';

function tempId() {
  // crypto.randomUUID is ideal; fallback for older browsers
  try { return `temp-${crypto.randomUUID()}`; } catch { return `temp-${Date.now()}-${Math.random()}`; }
}

export function useOptimisticTodos(initial: Todo[] = []) {
  const [todos, setTodos] = useState<Todo[]>(initial);
  // Track a per-item version to ignore stale responses
  const versions = useRef(new Map<string, number>());
  const bump = (id: string) => versions.current.set(id, (versions.current.get(id) ?? 0) + 1);
  const get = (id: string) => versions.current.get(id) ?? 0;

  async function add(title: string) {
    const id = tempId();
    const optimistic: Todo = { id, title, completed: false, optimistic: true };

    // Predict + render
    setTodos(prev => [optimistic, ...prev]);
    bump(id);
    const v = get(id);

    try {
      const saved = await createTodo(title);
      // Ignore if this optimistic item was removed/changed beyond this version
      if (get(id) !== v) return;
      setTodos(prev => prev.map(t => t.id === id ? { ...saved, optimistic: false } : t));
      // Transfer version to real id for future edits
      const oldV = versions.current.get(id) ?? 0;
      versions.current.delete(id);
      versions.current.set(saved.id, oldV);
    } catch (e) {
      // Roll back
      setTodos(prev => prev.filter(t => t.id !== id));
      // Surface error to caller for UI toast/snackbar
      throw e;
    }
  }

  async function toggle(id: string) {
    const current = todos.find(t => t.id === id);
    if (!current) return;

    const prevCompleted = current.completed;
    const nextCompleted = !prevCompleted;

    // Predict + render
    setTodos(prev => prev.map(t => t.id === id ? { ...t, completed: nextCompleted, optimistic: true } : t));
    bump(id);
    const v = get(id);

    try {
      await toggleTodo(id, nextCompleted);
      if (get(id) !== v) return; // stale response, ignore
      setTodos(prev => prev.map(t => t.id === id ? { ...t, optimistic: false } : t));
    } catch (e) {
      // Roll back
      setTodos(prev => prev.map(t => t.id === id ? { ...t, completed: prevCompleted, optimistic: false } : t));
      throw e;
    }
  }

  async function remove(id: string) {
    const snapshot = todos; // capture for rollback

    // Predict + render
    setTodos(prev => prev.filter(t => t.id !== id));
    bump(id);
    const v = get(id);

    try {
      await deleteTodo(id);
      if (get(id) !== v) return;
      // nothing else to do on success
    } catch (e) {
      // Roll back
      setTodos(snapshot);
      throw e;
    }
  }

  return { todos, add, toggle, remove };
}

Wiring it up in a component

// Todos.tsx
import { useState } from 'react';
import { useOptimisticTodos } from './useOptimisticTodos';

export function Todos({ initial = [] }: { initial?: any[] }) {
  const { todos, add, toggle, remove } = useOptimisticTodos(initial);
  const [title, setTitle] = useState('');
  const [error, setError] = useState<string | null>(null);

  async function onAdd(e: React.FormEvent) {
    e.preventDefault();
    if (!title.trim()) return;
    try {
      await add(title.trim());
      setTitle('');
    } catch (err: any) {
      setError(err?.message ?? 'Something went wrong');
    }
  }

  return (
    <div>
      <form onSubmit={onAdd} style={{ display: 'flex', gap: 8 }}>
        <input value={title} onChange={e => setTitle(e.target.value)} placeholder="New todo" />
        <button type="submit">Add</button>
      </form>

      {error && (
        <div role="status" aria-live="polite" style={{ color: 'crimson', marginTop: 8 }}>
          {error}
        </div>
      )}

      <ul style={{ marginTop: 12 }}>
        {todos.map(t => (
          <li key={t.id} style={{ opacity: t.optimistic ? 0.7 : 1 }}>
            <label>
              <input type="checkbox" checked={t.completed} onChange={() => toggle(t.id)} />
              {t.title}
            </label>
            <button onClick={() => remove(t.id)} style={{ marginLeft: 8 }}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Notes:

  • Optimistic items render with slight opacity for subtle feedback.
  • Role and aria-live ensure screen readers announce errors.
  • versions Map guards against stale responses overwriting fresher UI state.

Using a data-fetching library (TanStack Query)

Libraries such as TanStack Query, SWR, Relay, or Apollo provide built-in primitives for optimistic updates. Here’s the canonical TanStack Query pattern using onMutate for prediction, and onError/onSuccess for reconciliation.

// TodosQuery.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { createTodo, toggleTodo, deleteTodo } from './api';

const key = ['todos'];

export function TodosQuery() {
  const qc = useQueryClient();
  const { data: todos = [] } = useQuery({ queryKey: key, queryFn: async () => (await fetch('/api/todos')).json() });

  const addMutation = useMutation({
    mutationFn: (title: string) => createTodo(title),
    onMutate: async (title: string) => {
      await qc.cancelQueries({ queryKey: key });
      const previous = qc.getQueryData<any[]>(key) ?? [];
      const temp = { id: `temp-${crypto.randomUUID?.() ?? Math.random()}`, title, completed: false, optimistic: true };
      qc.setQueryData<any[]>(key, old => [temp, ...(old ?? [])]);
      return { previous, tempId: temp.id };
    },
    onError: (_err, _vars, ctx) => {
      if (ctx?.previous) qc.setQueryData(key, ctx.previous);
    },
    onSuccess: (saved, _vars, ctx) => {
      qc.setQueryData<any[]>(key, old => old?.map(t => (t.id === ctx?.tempId ? { ...saved, optimistic: false } : t)) ?? []);
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: key }); // final server truth
    }
  });

  const toggleMutation = useMutation({
    mutationFn: ({ id, completed }: { id: string; completed: boolean }) => toggleTodo(id, completed),
    onMutate: async ({ id, completed }) => {
      await qc.cancelQueries({ queryKey: key });
      const previous = qc.getQueryData<any[]>(key) ?? [];
      qc.setQueryData<any[]>(key, old => old?.map(t => (t.id === id ? { ...t, completed, optimistic: true } : t)) ?? []);
      return { previous, id };
    },
    onError: (_err, _vars, ctx) => { if (ctx?.previous) qc.setQueryData(key, ctx.previous); },
    onSettled: () => { qc.invalidateQueries({ queryKey: key }); }
  });

  const deleteMutation = useMutation({
    mutationFn: (id: string) => deleteTodo(id),
    onMutate: async (id: string) => {
      await qc.cancelQueries({ queryKey: key });
      const previous = qc.getQueryData<any[]>(key) ?? [];
      qc.setQueryData<any[]>(key, old => old?.filter(t => t.id !== id) ?? []);
      return { previous };
    },
    onError: (_err, _vars, ctx) => { if (ctx?.previous) qc.setQueryData(key, ctx.previous); },
    onSettled: () => { qc.invalidateQueries({ queryKey: key }); }
  });

  return (
    <div>
      <form onSubmit={e => { e.preventDefault(); const f = new FormData(e.currentTarget as HTMLFormElement); const title = String(f.get('title') || ''); if (title) addMutation.mutate(title); (e.currentTarget as HTMLFormElement).reset(); }}>
        <input name="title" placeholder="New todo" />
        <button type="submit">Add</button>
      </form>

      <ul style={{ marginTop: 12 }}>
        {todos.map((t: any) => (
          <li key={t.id} style={{ opacity: t.optimistic ? 0.7 : 1 }}>
            <label>
              <input type="checkbox" checked={t.completed} onChange={() => toggleMutation.mutate({ id: t.id, completed: !t.completed })} />
              {t.title}
            </label>
            <button onClick={() => deleteMutation.mutate(t.id)} style={{ marginLeft: 8 }}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Why this works well:

  • onMutate synchronously updates the cache and returns context for rollbacks.
  • onError reliably restores previous cache state.
  • onSettled invalidates to confirm with server truth and clear any drift.

About React’s useOptimistic hook

Recent React releases include a useOptimistic hook intended for optimistic patterns, especially with server actions and forms. If your React version provides it, you can streamline prediction and rollback within a single hook. The concepts above still apply: predict locally, render, then reconcile or revert on failure. Consult your framework docs (e.g., Next.js App Router) for integration details.

Handling edge cases and pitfalls

  • Temporary IDs: Always generate a stable temp id for new entities and replace it with the server id on success. Keep a map when needed.
  • Out-of-order responses: Use per-entity versions or request timestamps to ignore stale writes.
  • Duplicate clicks: Disable relevant controls while a specific entity is “optimistic,” or coalesce repeated actions.
  • Undo UX: For destructive actions (delete, archive), show a toast with Undo. Defer the server call for a few seconds or send it but keep a reversible record.
  • Validation failures: If the server rejects (e.g., title too long), roll back and keep the user’s original input focused with an inline message.
  • Access control: Don’t assume success if an action may be unauthorized; roll back and route to sign-in or permissions UI.
  • Realtime streams: If you also consume updates via WebSocket/SSE, de-duplicate server echoes of your own optimistic changes by comparing ids or clientMutationId.

Undo pattern example

// Defer deletion for 5s with an Undo window.
function useUndoableDelete(deleteFn: (id: string) => Promise<void>) {
  const timers = useRef(new Map<string, number>());
  const pending = useRef(new Set<string>());

  async function schedule(id: string, commit: () => void, rollback: () => void) {
    pending.current.add(id);
    commit(); // optimistic remove now
    const handle = window.setTimeout(async () => {
      pending.current.delete(id);
      try { await deleteFn(id); } catch { rollback(); }
    }, 5000);
    timers.current.set(id, handle);
  }

  function undo(id: string, rollback: () => void) {
    const handle = timers.current.get(id);
    if (handle) {
      clearTimeout(handle);
      timers.current.delete(id);
      pending.current.delete(id);
      rollback();
    }
  }

  return { schedule, undo, isPending: (id: string) => pending.current.has(id) };
}

Pagination, sorting, and filtering

Optimistic changes must reflect wherever the entity appears.

  • If you prepend newly created items to a paginated list, they might “disappear” when you paginate. Consider a logical “All items” cache alongside page caches, or invalidate all page queries after success.
  • If a filter would exclude a just-toggled item (e.g., show: completed only), you may need to move it between filtered views immediately.
  • For stable sort orders (by createdAt), predict a timestamp locally and refine after the server returns.

Offline and transient network errors

  • Queue writes: Store pending mutations in IndexedDB/localForage, apply optimistic state, and flush when online. Mark items with pendingState = ‘created’ | ‘updated’ | ‘deleted’ and reconcile on success.
  • Backoff and retry: Exponential backoff avoids overwhelming a flaky network. In TanStack Query, set retry and retryDelay; for manual hooks, implement a small retry policy.
  • User feedback: Show a lightweight “Saving…” banner when many items are pending; degrade gracefully if offline for extended periods.

Testing optimistic flows

  • Unit test reducers/hooks: Verify prediction, rollback, and reconciliation paths.
  • Mock network timing: Use fake timers to simulate latency and reordering (resolve deletes before creates to shake out race issues).
  • E2E checks: With Playwright or Cypress, assert that the UI updates immediately, then remains correct after the mocked server responds or fails.

Example with Jest/Vitest and React Testing Library:

it('optimistically adds then replaces temp id on success', async () => {
  mockServer.post('/api/todos').respondAfter(50, { id: '42', title: 'A', completed: false });
  render(<Todos initial={[]} />);
  await user.type(screen.getByPlaceholderText('New todo'), 'A');
  await user.click(screen.getByText('Add'));
  // Immediately visible with temp id
  expect(screen.getByText('A')).toBeInTheDocument();
  // After server
  await waitFor(() => expect(findTodoDomId('42')).toBeTruthy());
});

Accessibility and UX polish

  • Announce changes: Use aria-live regions to announce success/failure. Keep messages concise.
  • Subtle affordances: Dim optimistic items (opacity 0.7) or show a lightweight spinner badge on the row—not a blocking overlay.
  • Undo discoverability: Position the Undo toast near the interaction origin; keep the window short to reduce conflicts (2–5 seconds).

Observability and safety nets

  • Log error paths distinctly: optimistic_failure_rollback, optimistic_success_reconcile, optimistic_stale_ignored.
  • Feature flag: Roll out optimistic updates per action and per cohort; keep a kill switch.
  • Metrics: Track action latency (UI to server confirmation), rollback rate, and mismatch rate after invalidation.

Checklist for production

  • Generate stable temporary IDs for new entities.
  • Keep a revertible snapshot for each optimistic change.
  • Guard against stale responses (per-entity version or timestamp).
  • Handle duplicates and disable conflicting UI while pending.
  • Show clear, accessible feedback; provide Undo for destructive actions.
  • Invalidate/refetch after settlement to ensure server truth.
  • Test latency, failure, and reordering thoroughly.
  • Instrument rollbacks and maintain a feature flag.

Conclusion

Optimistic UI is one of the highest‑leverage performance techniques you can implement in React. By predicting a plausible result, rendering it immediately, and reconciling with server truth—plus guarding against failures and stale responses—you deliver a snappy, resilient experience. Start with the plain hooks approach to grasp the mechanics, then adopt a cache library like TanStack Query for a production‑grade implementation with fewer pitfalls and less boilerplate.

Related Posts