Optimistic Mutations with TanStack Query: The Four Hooks You Actually Need
Optimistic UI doesn't need a clever abstraction — it needs four well-placed hooks. Here's how onMutate, onError, onSuccess, and onSettled fit together, and where most implementations quietly leak bugs.
Optimistic Mutations with TanStack Query: The Four Hooks You Actually Need
Most "optimistic update" tutorials show you the happy path: the user clicks, the UI flips instantly, the request succeeds, life is good. Real apps don't get to live there. Networks fail. Two requests race. The user mashes a button. A coupon validation server-side rejects what the client thought was fine. Optimistic UI is only as good as its rollback story.
TanStack Query gives you a small surface — onMutate, onError, onSuccess, and onSettled — that, used together, handles every case cleanly. This post walks through how each one earns its keep, what to put in it, and where the bugs hide.
The mental model
An optimistic mutation is a tiny state machine:
- Predict. Patch the local cache to match what the server will eventually return.
- Snapshot. Hold onto the previous state so you can undo your prediction if you guessed wrong.
- Send. Fire the actual request.
- Confirm or revert. On success, narrow your invalidations to whatever derived state the server might have changed. On failure, restore the snapshot and surface the error.
That maps directly onto the four hooks. Once you internalize the mapping, you stop writing custom optimistic frameworks.
The four hooks
onMutate: snapshot, then patch
onMutate runs before the request is sent. It receives the mutation variables and returns a context object that flows into the other hooks. Two things happen here:
onMutate: async (variables) => {
// 1. Cancel any in-flight refetch so it can't clobber our patch.
await queryClient.cancelQueries({ queryKey });
// 2. Snapshot the current cache value.
const previous = queryClient.getQueryData<CacheShape>(queryKey);
// 3. Patch the cache to the predicted next state.
if (previous) {
queryClient.setQueryData<CacheShape>(queryKey, applyOptimistic(previous, variables));
}
// 4. Return the snapshot so onError can roll back.
return { previous };
},
The cancelQueries line is the one most tutorials skip. Without it, a refetch that started before your onMutate can finish after your patch and silently overwrite it. You'll see the UI flip optimistically, then jerk back to the pre-mutation state for half a second before the server response arrives. cancelQueries is what stops that.
onError: roll back
The context returned from onMutate is the rollback payload:
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
toast.error("Something went wrong");
},
Two subtleties:
- Always check
context?.previousexists. IfonMutatethrew, or the cache was empty, there's nothing to restore. - Don't refetch in
onError. A common reflex isinvalidateQuerieshere "to be safe." But you already have the correct pre-mutation state in your snapshot, and refetching after a failure causes a visible loading flicker for no reason.
onSuccess: invalidate narrowly
This is where most teams get it wrong. The default reflex is queryClient.invalidateQueries({ queryKey: ['cart'] }) on every success. That nukes the cart cache and forces a full refetch — which displays a loader, which defeats the whole point of optimism.
The correct rule: invalidate only the derived state that the server might have changed in ways you couldn't predict.
For a cart-item update, you predicted item quantities perfectly. You can't predict server-side coupon validation, points discounts, or VAT rounding. So:
onSuccess: () => {
// DON'T do this — it triggers a refetch of data we already patched correctly.
// queryClient.invalidateQueries({ queryKey: ['cart'] });
// DO this — only the pricing query, which depends on derived server math.
queryClient.invalidateQueries({ queryKey: ['cart-pricing'] });
},
The optimistic cart items stay put. The pricing summary refetches and reconciles. No loader on the items list, no jank.
If your prediction is exact — say, a toggle that just flips a boolean — you can skip onSuccess invalidation entirely. The cache is already correct.
onSettled: the safety net (when you need it)
onSettled runs after both onSuccess and onError. It's where you put logic that should fire regardless of outcome — typically the safest possible refetch as a last-resort consistency check.
For most cases you don't need it. But if your optimistic prediction can drift from server state in subtle ways (server-side dedup, race conditions, multi-tab edits), an onSettled invalidation gives you a self-healing cache:
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
The trade-off: every successful mutation costs a refetch. Use onSettled invalidation only when correctness > network cost.
A complete example
Here's a cart-item quantity update with optimistic UI, end to end:
export const useUpdateCartItemQuantity = () => {
const guestId = useMainStore((s) => s.guestId);
const branchId = useMainStore((s) => s.deliveryBranch?.id);
const cartKey = ['cart', guestId, branchId];
return useMutation({
mutationFn: ({ cartItem, delta }) =>
updateCartItemQuantity(guestId, cartItem, delta),
onMutate: async ({ cartItem, delta }) => {
await queryClient.cancelQueries({ queryKey: cartKey });
const previous = queryClient.getQueryData<Cart>(cartKey);
if (previous) {
queryClient.setQueryData<Cart>(cartKey, {
...previous,
totalItems: previous.totalItems + delta,
cartItems: previous.cartItems.map((it) =>
it.productId === cartItem.productId
? {
...it,
quantity: it.quantity + delta,
total: Number((it.price * (it.quantity + delta)).toFixed(2)).toString(),
}
: it
),
});
}
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData<Cart>(cartKey, context.previous);
}
toast.error('Failed to update cart');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cart-pricing'] });
toast.success('Cart updated');
},
});
};
Four blocks. Each one earns its place. None of them are decorative.
The bugs that hide in optimistic code
In no particular order, the issues I've seen most often:
- Forgetting
cancelQueries. A refetch in flight overwrites the optimistic patch. The UI flickers backward. - Snapshotting after the patch. The snapshot has to be taken before
setQueryData, not after. Otherwise the rollback restores the optimistic state, not the original. - Returning
undefinedfromonMutatewhen the cache is empty. Thencontext?.previousis undefined inonError, rollback silently no-ops, and a failed mutation leaves the optimistic state in place. - Invalidating the same query you just patched. Triggers a loader, defeats the optimism.
- Optimistically updating derived data you can't actually compute. If the server applies a discount you can't replicate client-side, don't pretend you can. Patch what you know, leave the rest to refetch.
- Not handling concurrent mutations. If a user clicks +1 twice in quick succession, both mutations call
onMutateand snapshot. The second snapshot already includes the first patch. If the first fails, you roll back to a state that includes the second click. Use a queue oruseMutation'smutationKey+scopeif this matters.
When not to be optimistic
Optimism is a UX accelerator, not a correctness story. Reach for it when:
- The prediction is cheap and correct in the >99% case.
- The user is actively waiting on the result.
- The wrong outcome is recoverable (rollback + toast is acceptable).
Skip it when:
- The server's response carries data you couldn't compute (a generated ID you need to navigate to, a server-side validation you can't replicate, a price that depends on inventory state).
- The mutation has side effects that your rollback can't undo (sent emails, charged cards).
- You're inside a flow where the user should see the loading state — first-time form submissions, payment confirmations.
The takeaway
Optimistic UI in TanStack Query is four hooks, used in this exact way:
onMutate: cancel in-flight, snapshot, patch, return the snapshot.onError: restore from the snapshot in context.onSuccess: invalidate only what you couldn't predict.onSettled(optional): a safety-net invalidation when correctness > network cost.
No libraries on top, no custom abstractions, no "optimistic store." Just the four hooks pulling their weight.
Rate this post
All fields are optional. Just stars is fine.