Taming Optimistic Mutations in TanStack Query Version 5

Nothing ruins the illusion of an instant user interface faster than an uncancelled background fetch overwriting your optimistic state half a second later.

VW
Viery Wahyu•Author
•8 min read

Every product manager loves the idea of optimistic updates until the first production bug report lands in the engineering channel. On paper, updating the user interface the exact millisecond a button is clicked—before the network request even leaves the browser—makes a web application feel like a native desktop binary. In reality, lying to your state management layer about what currently exists in the database is one of the fastest ways to trigger maddening cache invalidation bugs. Over the years of building high-concurrency booking dashboards and live point-of-sale terminals, I have watched poorly implemented optimistic mutations cause status badges to flicker backward in time, duplicate items across paginated lists, and leave operators staring at phantom confirmations after a silent network timeout.

How Uncancelled Background Refetches Create Zombie State

The most painful cache war story I ever debugged happened on an internal dispatch screen where operators rapidly toggled order fulfillment statuses during peak lunch hours. An operator would click a button to mark an order as dispatched, the badge would immediately turn green through our optimistic update handler, and then half a second later the badge would mysteriously snap back to yellow right in front of their eyes. Frustrated, the operator would click the button a second time, firing duplicate webhook notifications to the courier while the database had actually succeeded on the very first click.

When we traced the network timeline, we realized our optimistic mutation was losing a race against TanStack Query's own background synchronization engine. Just milliseconds before the operator clicked the mutation button, the browser window had regained focus, triggering an automatic background GET request for the order list. While that slow read request was still traveling over the wire, our optimistic handler synchronously mutated the local cache to green. Fifty milliseconds later, the older background read request finally resolved with stale data from the past and overwrote our fresh optimistic cache entry right back to yellow.

Fixing that race condition permanently required enforcing an ironclad rule across every cache-level optimistic update in our codebase. Before you ever touch setQueryData inside an onMutate callback, you must await queryClient.cancelQueries for the exact query key you are about to modify so any in-flight network reads are aborted immediately before they can clobber your local state.

typescript
1import { useMutation, useQueryClient } from "@tanstack/react-query"
2import { updateOrderStatus, type OrderRecord } from "@/lib/api/orders"
3
4export function useOptimisticOrderStatus() {
5 const queryClient = useQueryClient()
6 const ordersQueryKey = ["orders", "active-dispatch"] as const
7
8 return useMutation({
9 mutationFn: updateOrderStatus,
10 onMutate: async (newStatusPayload) => {
11 // Abort any outgoing refetches so they never overwrite our optimistic update
12 await queryClient.cancelQueries({ queryKey: ordersQueryKey })
13
14 // Snapshot the previous cache value for atomic rollback on failure
15 const previousOrders = queryClient.getQueryData<OrderRecord[]>(ordersQueryKey)
16
17 // Optimistically mutate the cache in place
18 queryClient.setQueryData<OrderRecord[]>(ordersQueryKey, (oldOrders) => {
19 if (!oldOrders) return []
20 return oldOrders.map((order) =>
21 order.id === newStatusPayload.orderId
22 ? { ...order, status: newStatusPayload.status }
23 : order
24 )
25 })
26
27 return { previousOrders }
28 },
29 onError: (_err, _variables, context) => {
30 if (context?.previousOrders) {
31 queryClient.setQueryData(ordersQueryKey, context.previousOrders)
32 }
33 },
34 onSettled: () => {
35 queryClient.invalidateQueries({ queryKey: ordersQueryKey })
36 },
37 })
38}

Treating onMutate, onError, and onSettled as an indivisible transactional triad restores determinism to client-side state. Canceling outgoing queries stops stale reads in their tracks, returning the previousOrders snapshot from onMutate passes a clean rollback reference straight into the context parameter of onError, and firing invalidateQueries inside onSettled guarantees that whether the server succeeded or threw an exception, the client cache eventually reconciles with the true database state.

Escaping Cache Gymnastics With Version 5 UI-Level Updates

While manual cache manipulation is necessary when a single mutation needs to reflect across five different dashboard widgets simultaneously, writing twenty lines of immutable array mapping logic for every minor toggle quickly bloats a codebase. Worse still, if your screen displays data across multiple filtered, sorted, or infinitely paginated query keys, trying to guess which cached array slices contain the mutated record inside onMutate turns into a maintenance nightmare.

Migrating our applications to TanStack Query Version 5 gave us a much cleaner architectural escape hatch for localized optimistic updates. Instead of reaching into the global query cache and mutating stored arrays before the server responds, Version 5 encourages deriving the optimistic state directly inside the rendering layer using the active mutation's variables and isPending state, or reading in-flight mutations globally via the useMutationState hook.

typescript
1"use client"
2
3import { useMutationState, useQuery } from "@tanstack/react-query"
4import { fetchActiveOrders, type OrderStatusPayload } from "@/lib/api/orders"
5
6export function LiveOrderBoard() {
7 const { data: orders = [] } = useQuery({
8 queryKey: ["orders", "active-dispatch"],
9 queryFn: fetchActiveOrders,
10 })
11
12 // Subscribe to all currently pending order status mutations across the app
13 const pendingMutations = useMutationState<OrderStatusPayload>({
14 filters: { mutationKey: ["update-order-status"], status: "pending" },
15 select: (mutation) => mutation.state.variables as OrderStatusPayload,
16 })
17
18 return (
19 <div className="grid gap-4">
20 {orders.map((order) => {
21 const inFlightOverride = pendingMutations.find(
22 (item) => item?.orderId === order.id
23 )
24 const displayStatus = inFlightOverride ? inFlightOverride.status : order.status
25
26 return (
27 <div
28 key={order.id}
29 className="flex items-center justify-between rounded-lg border p-4"
30 >
31 <span className="font-medium">Order #{order.code}</span>
32 <span className="rounded bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-900">
33 {displayStatus}
34 </span>
35 </div>
36 )
37 })}
38 </div>
39 )
40}

Look at how much defensive complexity disappears when you shift optimistic projection into the render cycle with useMutationState. We no longer need to snapshot previous arrays, write manual rollback handlers in onError, or worry about corrupting normalized cache structures when a network request fails. As long as a mutation tagged with ["update-order-status"] is in flight, pendingMutations exposes its raw input variables to any component on the screen. The moment the mutation settles and triggers an invalidation refetch, the pending entry vanishes automatically and the component seamlessly transitions to rendering the authoritative server data. If the mutation fails, the pending state clears and the UI reverts to order.status automatically without a single line of rollback code.

Handling Concurrent Rapid Clicks Without Cache Thrashing

One final edge case that still bites engineering teams even in Version 5 is what happens when a user clicks the same optimistic toggle three times in rapid succession before the first request finishes. If your onSettled handler blindly triggers invalidateQueries after every single mutation completion, the first mutation finishing will trigger a refetch that arrives right in the middle of the third mutation, briefly flashing an intermediate server state on the screen.

Guarding against rapid-fire mutation overlap requires checking how many sibling mutations are still running in the background before allowing onSettled to invalidate the cache. By calling queryClient.isMutating({ mutationKey: ["update-order-status"] }) inside onSettled and only firing invalidateQueries when the active count drops to exactly one—representing the current mutation that is just about to finish—you ensure that the browser waits until the user's final click has settled on the server before pulling down the canonical state.

Picking the Right Optimistic Strategy for the Job

Taming optimistic updates in TanStack Query Version 5 ultimately comes down to matching the complexity of your implementation to the blast radius of the user action. For ninety percent of everyday UI interactions like toggling a bookmark, renaming a title, or appending an item to a visible list, projecting pending variables or reading useMutationState in the component tree gives you instant visual feedback with zero risk of cache corruption. Reserve full onMutate cache surgery for complex cross-page mutations where external components depend on the underlying cache store changing immediately, and never touch setQueryData without canceling in-flight queries first.

VW

Written by

Viery Wahyu

📍 Bali, Indonesia

Software Engineer

Software engineer based in Bali with over a decade of experience building fast web applications

The Journal

View all →