Mutations and Optimistic Updates
- react
- react-query
- typescript
- nextjs
- frontend
- state-management
- javascript
- web-development
- performance
- ux
In Data Fetching with React Query, I covered how to fetch, cache, and revalidate server state without hand-rolling loading flags for every screen. That solves the read side. The write side, creating a comment, updating a profile, deleting a row, comes with its own set of decisions, and getting them wrong is what makes an app feel laggy even when the backend responds in a couple hundred milliseconds.
This is part of the React & Next.js Complete Guide series, which builds up from React Fundamentals for Professionals toward production Next.js apps. Mutations sit at the intersection of client state and server state, so this is where a lot of subtle bugs live: stale caches, duplicate submissions, and UI that flickers back to an old value after an update "succeeds."
I want to walk through mutations as React Query models them, then get into optimistic updates specifically, because that's the part teams either skip entirely (leaving every interaction feeling sluggish) or implement carelessly (leaving the UI showing data that never actually happened on the server).
Why mutations need their own abstraction
A query and a mutation look similar on the surface. Both call an API. Both can succeed or fail. But they have different lifecycles and different concerns.
A query is idempotent and cacheable. You can retry it silently, run it in the background, and share its result across every component that asks for the same data. A mutation is not idempotent by default. Submitting a form twice usually means creating two records, not one. It has side effects the server has to apply exactly once, and the client's job is coordinating around that: showing pending state, handling failure, and making sure any cached queries that the mutation affects get updated or invalidated.
useMutation in React Query exists because that coordination is fiddly enough to get wrong by hand. Here's the baseline version, no optimism yet:
import { useMutation, useQueryClient } from "@tanstack/react-query";
type CreateCommentInput = {
postId: string;
body: string;
};
type Comment = {
id: string;
postId: string;
body: string;
authorId: string;
createdAt: string;
};
async function createComment(input: CreateCommentInput): Promise<Comment> {
const res = await fetch(`/api/posts/${input.postId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: input.body }),
});
if (!res.ok) {
throw new Error(`Failed to create comment: ${res.status}`);
}
return res.json();
}
function useCreateComment(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createComment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["comments", postId] });
},
});
}
This is already better than a raw fetch call in an event handler. isPending, isError, and error come for free, retries are configurable, and onSuccess gives you one place to say "the comments list for this post is now stale, go refetch it." For a lot of internal tools and low-frequency actions, this is genuinely enough. Don't add optimistic updates until the plain invalidate-and-refetch flow visibly feels slow, because optimism adds real complexity in exchange for perceived speed.
The pitfall of invalidate-then-refetch on every action
The pattern above works, but it has a cost: after the mutation resolves, you refetch the whole list, wait for that request, and only then does the UI reflect the change. For a comment section, that's a beat of delay between "I clicked submit" and "I see my comment." For a checkbox toggle on a task list, that beat is much more noticeable, because the expected behavior is instant.
The instinct to fix this by skipping the invalidate and just trusting the mutation's response is reasonable for simple cases, but it breaks down once other queries depend on the same data. If your task list query and your task count query both derive from the same underlying resource, updating one cache entry in onSuccess and forgetting the other leaves you with a UI that's inconsistent until the next full refetch. This is the actual argument for optimistic updates. They force you to be explicit about every place a piece of state is rendered, instead of hoping a background refetch quietly fixes it later. The nicer UX is a side effect of that discipline, not the reason to reach for it.
Optimistic updates: assume success, reconcile on failure
An optimistic update changes the local cache immediately, before the server confirms anything, then rolls back if the mutation fails. React Query gives you the hooks to do this correctly through onMutate, onError, and onSettled.
import { useMutation, useQueryClient } from "@tanstack/react-query";
type Task = {
id: string;
title: string;
completed: boolean;
};
type ToggleTaskInput = {
taskId: string;
completed: boolean;
};
async function toggleTask(input: ToggleTaskInput): Promise<Task> {
const res = await fetch(`/api/tasks/${input.taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: input.completed }),
});
if (!res.ok) {
throw new Error(`Failed to update task: ${res.status}`);
}
return res.json();
}
function useToggleTask(listId: string) {
const queryClient = useQueryClient();
const queryKey = ["tasks", listId];
return useMutation({
mutationFn: toggleTask,
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey });
const previousTasks = queryClient.getQueryData<Task[]>(queryKey);
queryClient.setQueryData<Task[]>(queryKey, (old) =>
old?.map((task) =>
task.id === input.taskId
? { ...task, completed: input.completed }
: task
)
);
return { previousTasks };
},
onError: (_error, _input, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(queryKey, context.previousTasks);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
}
Three things matter in this sequence, and skipping any one of them is where optimistic updates go wrong in practice.
Canceling in-flight queries before mutating the cache prevents a race where a background refetch resolves after your optimistic write and overwrites it with stale data. Snapshotting the previous value in onMutate and returning it as context is what makes rollback possible; without it, onError has nothing to restore. Calling invalidateQueries in onSettled, which runs on both success and failure, is what reconciles the client's optimistic guess with whatever the server actually persisted, including any fields the server computed that the client didn't know about.
Handling failure without surprising the user
The rollback in onError restores the previous cache value, but that alone can feel abrupt: the checkbox flips checked, then a moment later flips back unchecked with no explanation. Pair the rollback with a visible signal, a toast or an inline error, so the reversal reads as "this failed" rather than as a rendering glitch.
const toggleTask = useToggleTask(listId);
function handleToggle(taskId: string, nextCompleted: boolean) {
toggleTask.mutate(
{ taskId, completed: nextCompleted },
{
onError: () => {
showToast({
variant: "error",
message: "Couldn't update the task. Please try again.",
});
},
}
);
}
Mutation-level callbacks passed to .mutate() run in addition to the ones defined in the hook itself, so you get the cache-management logic centralized in useToggleTask and the presentation logic (toasts, focus management) in the component that actually knows what the user is looking at. Keep that split. A hook that both manages the cache and decides how to notify the user ends up hard to reuse anywhere else.
Deciding what's actually worth making optimistic
Not every mutation deserves this treatment, and treating optimism as a default you apply everywhere adds maintenance cost for no real benefit. A rough rule I use: optimistic updates earn their complexity when the action is frequent, low-risk to reverse, and the server round trip would otherwise be visible in the interaction, checkboxes, likes, reordering, inline edits. They're a poor fit for anything where a failure is expensive to explain after the fact, like payments, account deletion, or anything that fans out to other systems (sending an email, charging a card) where "actually, that didn't happen" is a bad experience even with a clean rollback.
There's also a correctness angle specific to multi-tenant SaaS: if the mutation's server-side outcome can depend on data the client doesn't have, another user's concurrent edit, a role check, a rate limit, then the optimistic guess can diverge from reality more often than you'd expect, and the rollback path gets exercised constantly. In that situation, invalidate-and-refetch with a fast, obvious pending state (a spinner on the button, a disabled row) is often the more honest UI, because it doesn't promise something the server might not deliver.
Key takeaways
- Mutations and queries need different handling: queries are idempotent and cacheable, mutations have side effects that must apply exactly once.
- Start with plain invalidate-and-refetch mutations. Reach for optimistic updates only once the round-trip delay is visibly a problem.
- A correct optimistic update cancels in-flight queries, snapshots the previous cache value for rollback, applies the optimistic write, and reconciles with `onSettled`.
- Pair rollbacks with a visible error signal so a reversed update reads as a failure, not a rendering bug.
- Optimistic updates fit frequent, low-risk, reversible actions best. Avoid them for payments, irreversible operations, or anything where server-side outcomes depend on data the client can't see.
- Keep cache-management logic in the mutation hook and presentation logic (toasts, focus) in the component calling it.
Frequently asked questions
Do I need optimistic updates for every mutation in my app?
No. Most mutations are fine with a plain `useMutation` and an `invalidateQueries` call in `onSuccess`. Reserve optimistic updates for interactions where the perceived delay is actually noticeable and the action is easy to reverse.
What happens if two optimistic updates for the same query overlap?
This is exactly why `onMutate` calls `cancelQueries` first. Without it, an in-flight refetch from the first mutation can resolve after the second one's optimistic write and clobber it with outdated data. Canceling makes each mutation's optimistic write the source of truth until `onSettled` reconciles everything.
Should optimistic updates use the mutation's response or the input value?
Use the input value in `onMutate`, since you don't have a server response yet. Let `onSettled`'s invalidation (or the response data in `onSuccess`) replace the optimistic guess with the server's actual state, including any fields the server computes, like timestamps or IDs.
Why does my rollback flicker instead of feeling like an error?
A silent cache rollback with no accompanying message reads as a UI bug to the user. Always pair the rollback in `onError` with a visible signal, a toast, an inline message, so the reversal is understood as "this action failed," not as flakiness.
Is this pattern specific to React Query, or does it generalize?
The three-step shape, snapshot, optimistic write, reconcile, applies regardless of library. React Query gives you `onMutate`/`onError`/`onSettled` as named hooks for it, but you'd implement the same sequence by hand with SWR, Redux Toolkit Query, or a custom fetch wrapper. The library mainly saves you from re-deriving the race-condition handling yourself.
How does this interact with Next.js Server Actions?
Server Actions handle the mutation call itself and can integrate with `revalidatePath` or `revalidateTag` for server-driven cache invalidation, but they don't give you client-side optimistic cache writes out of the box. If you need instant UI feedback from a Server Action, you still combine it with `useOptimistic` (React's built-in hook for this) or with a client-side cache like React Query sitting in front of the action.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.