Hazem Azzam

All posts
Writing

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.

4 min read5.0(1)
reacttanstack-queryfrontendreact-query

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:

  1. Predict. Patch the local cache to match what the server will eventually return.
  2. Snapshot. Hold onto the previous state so you can undo your prediction if you guessed wrong.
  3. Send. Fire the actual request.
  4. 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?.previous exists. If onMutate threw, or the cache was empty, there's nothing to restore.
  • Don't refetch in onError. A common reflex is invalidateQueries here "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:

  1. Forgetting cancelQueries. A refetch in flight overwrites the optimistic patch. The UI flickers backward.
  2. Snapshotting after the patch. The snapshot has to be taken before setQueryData, not after. Otherwise the rollback restores the optimistic state, not the original.
  3. Returning undefined from onMutate when the cache is empty. Then context?.previous is undefined in onError, rollback silently no-ops, and a failed mutation leaves the optimistic state in place.
  4. Invalidating the same query you just patched. Triggers a loader, defeats the optimism.
  5. 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.
  6. Not handling concurrent mutations. If a user clicks +1 twice in quick succession, both mutations call onMutate and 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 or useMutation's mutationKey + scope if 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.

5.0(1)