React cookbook · Intermediate

Add optimistic updates to a todo list

Make toggling and deleting items feel instant while staying correct if the server request fails.

Approach

With TanStack Query, update the cached list immediately in onMutate, keep a snapshot to roll back in onError, and invalidate in onSettled to sync with the server. Cancel in-flight queries first so they don't overwrite the optimistic state.

Solution

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

export function useDeleteTodo() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (id: string) => {
      const res = await fetch(`/api/todos/${id}`, { method: "DELETE" });
      if (!res.ok && res.status !== 404) throw new Error("Delete failed");
    },
    onMutate: async (id) => {
      await qc.cancelQueries({ queryKey: ["todos"] });
      const previous = qc.getQueryData<Todo[]>(["todos"]);
      qc.setQueryData<Todo[]>(["todos"], (old = []) => old.filter((t) => t.id !== id));
      return { previous };
    },
    onError: (_err, _id, ctx) => {
      qc.setQueryData(["todos"], ctx?.previous);
      toast.error("Couldn't delete the item");
    },
    onSettled: () => qc.invalidateQueries({ queryKey: ["todos"] }),
  });
}

// <button onClick={() => deleteTodo.mutate(todo.id)} aria-label={`Delete ${todo.title}`}>Delete</button>

Pitfalls

  • Forgetting the rollback, leaving the UI lying after failures.
  • Treating 404 on DELETE as an error (it's already gone — idempotent).
  • Optimistic updates for operations that commonly fail validation.

Variations

  • React 19 useOptimistic for form Actions.
  • Show a pending style (opacity) instead of removing immediately.

Further reading: TanStack Query: Optimistic updates

Reference: https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

More React recipes