TanStack Query + Zustand Optimistic Updates — A Practical Guide to useMutation · onMutate · queryOptions
Honestly, I used to think, "Can't I just manage everything with Zustand alone?" I'd dump API response data into Zustand, throw modal open states into the same store... and after a few months of that, sync bugs started creeping in. The server would send an update but the UI would still show stale data, and refreshing would suddenly make it correct — have you ever experienced that frustrating moment?
This post is aimed at frontend developers who are comfortable with React hooks and have used a state management library like Zustand or Redux at least once. I'll walk through how TanStack Query's useMutation · onMutate · queryOptions and Zustand divide responsibilities for optimistic updates, with real-world code. I've also included concurrent mutation race condition handling that the official docs don't cover well, so by the end you'll walk away with patterns you can put to use immediately in production.
Core Concepts
Server State vs. UI State — Why Keep Them Separate?
We call it all "state," but there are two fundamentally different kinds of data.
| Category | Tool | Characteristics |
|---|---|---|
| Server State | TanStack Query | Asynchronous, cached, revalidated, must stay in sync with the server |
| UI State (Client State) | Zustand | Synchronous, client-only, meaningful only within the session |
A to-do list fetched from the server is server state. It can be modified at any time by another tab or another user, and may be different after a refresh. On the other hand, "is the sidebar open," "which item is selected," and "what is the filter set to" are UI state. They live and die entirely on the client, independent of the server.
If you put both in the same Zustand store, you have to manually update the store every time an API response arrives, and you can't know when data changes in another tab. You'd also have to implement cache expiration yourself. In the end, you'd be hand-rolling solutions to problems TanStack Query has already solved.
The Core Rule: Never copy server data into Zustand. Keeping the same data in two places makes sync bugs inevitable.
The Full Optimistic Update Flow
Optimistic Update is a pattern where you update the UI immediately without waiting for a server response, then either confirm or roll back based on the server result. The like count incrementing instantly when you press a like button, or an item appearing immediately when added to a cart — these are all this pattern.
User action occurs
→ [Immediately] Optimistically reflect in UI (directly manipulate TQ cache or Zustand)
→ [Async] Send actual request to server (useMutation)
→ Success: replace temp ID via onSuccess → invalidateQueries in onSettled
→ Failure: roll back to previous state (restore snapshot or call Zustand revert function)TanStack Query lets you handle this flow structurally through the useMutation callback chain: onMutate → onSuccess/onError → onSettled. You can see exactly what role each callback plays in the examples below.
queryOptions Factory Pattern (Recommended by the v5 Official Migration Guide)
The queryOptions() helper introduced in TanStack Query v5 is a pattern recommended by the official migration guide. It lets you define a query key and configuration in one place and reuse them type-safely across useQuery, prefetchQuery, invalidateQueries, and setQueryData.
// queries/todos.ts
import { queryOptions } from '@tanstack/react-query'
import { fetchTodo, fetchTodos } from '../api/todos' // fetch wrapper or axios instance
export const todoQueries = {
all: () =>
queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
}),
detail: (id: string) =>
queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
}),
}With this in place, you can write invalidateQueries(todoQueries.all()) instead of hardcoding query key strings, and TypeScript catches key mismatches at compile time. If you've ever spent time debugging a cache invalidation failure caused by a typo in a query key, you'll immediately appreciate the value of this pattern.
Practical Application
Example 1: Optimistic Update by Directly Manipulating the TanStack Query Cache
This is the officially recommended TanStack Query approach. It implements optimistic UI by directly manipulating the queryClient cache, with no separate Zustand store. It's the cleanest option when data is consumed within a single component tree.
// hooks/useTodoMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { todoQueries } from '../queries/todos'
import type { NewTodo, Todo } from '../types/todo' // domain type declaration location
function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ['todos'],
mutationFn: (newTodo: NewTodo) => api.addTodo(newTodo), // api: fetch wrapper or axios instance
onMutate: async (newTodo) => {
// 1. Cancel in-flight refetches — skipping this means a background refetch will
// immediately overwrite your optimistic update. I once spent 30 minutes debugging
// because of this missing line...
await queryClient.cancelQueries(todoQueries.all())
// 2. Save a snapshot of the previous state for rollback on failure
const previousTodos = queryClient.getQueryData(todoQueries.all().queryKey)
// 3. Optimistically update the cache — reflect in UI immediately before server responds
// Pass tempId in context so onSuccess can replace it with the server ID
const tempId = `temp-${Date.now()}`
queryClient.setQueryData(
todoQueries.all().queryKey,
(old: Todo[] | undefined) => [
...(old ?? []),
{ ...newTodo, id: tempId, status: 'pending' as const },
]
)
return { previousTodos, tempId }
},
onSuccess: (serverTodo, _newTodo, context) => {
// 4. Replace the temporary item with the actual ID received from the server
// Skipping this step leaves temp-xxx IDs behind, causing subsequent update/delete
// mutations to fail
queryClient.setQueryData(
todoQueries.all().queryKey,
(old: Todo[] | undefined) =>
(old ?? []).map((todo) =>
todo.id === context?.tempId ? serverTodo : todo
)
)
},
onError: (_err, _newTodo, context) => {
// 5. Roll back precisely to the previous state on failure
queryClient.setQueryData(
todoQueries.all().queryKey,
context?.previousTodos
)
},
onSettled: () => {
// 6. Final sync with server data regardless of success or failure
queryClient.invalidateQueries(todoQueries.all())
},
})
}Here's a summary of each callback's role:
| Callback | Timing | Role |
|---|---|---|
onMutate |
Just before the mutation starts | Cancel refetches → save snapshot → optimistically update cache |
onSuccess |
When server request succeeds | Replace temporary ID with the server response ID |
onError |
When server request fails | Restore cache from the snapshot in context |
onSettled |
Regardless of success or failure | Resync server data via invalidateQueries |
Also note that old in setQueryData is typed as Todo[] | undefined. Because the cache may not have data yet, the defensive ...(old ?? []) pattern is the right call for runtime safety.
Now let's move on to situations where multiple components need to share optimistic UI simultaneously.
Example 2: Zustand + TanStack Query Role-Separation Pattern
Imagine a scenario where the cart icon badge in the header and the cart list in a side panel both need to update at the same time. You can accomplish this with TQ cache alone, but when you need to propagate an immediate UI reaction across multiple component trees, Zustand is a better fit.
One thing to clarify upfront: the optimisticItems in the code below is not a copy of server data. It's a temporary optimistic state that exists only while the mutation is in progress. Once the server responds, onSettled clears this state, and from that point on the TQ cache's server data is the single source of truth.
// store/cartStore.ts
import { create } from 'zustand'
import type { CartItem } from '../types/cart'
interface CartStore {
optimisticItems: CartItem[] // temporary items before server confirmation (not a copy of server data)
isOptimisticUpdating: boolean
addItemOptimistic: (item: CartItem) => void
revertAddItem: (itemId: string) => void
settleOptimistic: () => void // clear optimistic state after mutation completes
}
const useCartStore = create<CartStore>((set) => ({
optimisticItems: [],
isOptimisticUpdating: false,
addItemOptimistic: (item) =>
set((state) => ({
optimisticItems: [...state.optimisticItems, item],
isOptimisticUpdating: true,
})),
revertAddItem: (itemId) =>
set((state) => ({
optimisticItems: state.optimisticItems.filter((i) => i.id !== itemId),
isOptimisticUpdating: false,
})),
settleOptimistic: () =>
set({ optimisticItems: [], isOptimisticUpdating: false }),
}))// hooks/useCartMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCartStore } from '../store/cartStore'
import type { CartItem } from '../types/cart'
function useAddToCart() {
const { addItemOptimistic, revertAddItem, settleOptimistic } = useCartStore()
const queryClient = useQueryClient()
return useMutation({
mutationFn: (item: CartItem) => api.addToCart(item),
onMutate: (item) => {
// Reflect in UI immediately via Zustand — delivered instantly to all subscribing components
addItemOptimistic(item)
return { itemId: item.id }
},
onError: (_err, _item, context) => {
// Remove the optimistically added item on failure
if (context) revertAddItem(context.itemId)
},
onSettled: () => {
// Clear optimistic state — from this point on, TQ server data is the source of truth
settleOptimistic()
queryClient.invalidateQueries({ queryKey: ['cart'] })
},
})
}In the component, you can decide what data to display based on the isOptimisticUpdating state.
// CartDisplay.tsx
import { useQuery } from '@tanstack/react-query'
import { useCartStore } from '../store/cartStore'
function CartDisplay() {
const { data: serverCart } = useQuery({ queryKey: ['cart'], queryFn: fetchCart })
const { optimisticItems, isOptimisticUpdating } = useCartStore()
// During optimistic update: merge server data + optimistic items for display
// After completion: use only TQ server data
const displayItems = isOptimisticUpdating
? [...(serverCart?.items ?? []), ...optimisticItems]
: serverCart?.items ?? []
return <CartList items={displayItems} />
}This pattern intentionally allows related data to coexist in both Zustand and TQ briefly. Because the Zustand state is cleared the moment the mutation completes, there's no long-term dual-source-of-truth problem.
That covers the basic optimistic update patterns. Now let's tackle a trickier case.
Advanced: Handling Race Conditions with Concurrent Mutations
Imagine a user rapidly adding multiple to-do items. It's possible that while the first mutation's onSettled triggers a refetch via invalidateQueries, that refetch result overwrites the cache that the second mutation has already updated optimistically.
You might think "can't I just use refetchOnWindowFocus: !queryClient.isMutating(...)?" — but this doesn't work as intended. refetchOnWindowFocus is a static option evaluated only once when useQuery runs; it doesn't automatically re-evaluate as mutations start and finish. isMutating() only returns a snapshot at that render moment.
The approach TkDodo actually recommends is to set global mutation defaults via queryClient.setMutationDefaults, then in onSettled, check whether any other mutations are still in flight and only run revalidation when the last mutation finishes.
// Set global defaults for todos mutations when configuring QueryClient
// (typically written in the app initialization file or right after QueryClient is created)
queryClient.setMutationDefaults(['todos'], {
onMutate: async () => {
// Cancel any in-flight refetches before any todos mutation starts
await queryClient.cancelQueries(todoQueries.all())
},
onSettled: async () => {
// If other todos mutations are still in progress, defer revalidation
// Only sync with server once when the last mutation finishes — reduces unnecessary refetches
if (!queryClient.isMutating({ mutationKey: ['todos'] })) {
await queryClient.invalidateQueries(todoQueries.all())
}
},
})Centralizing common logic with setMutationDefaults means you don't have to repeat the same onMutate/onSettled code in every individual useMutation. The value of this pattern grows as the number of mutation types increases.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Clear separation of concerns | Server state and UI state don't mix, making behavior predictable and debugging easier |
| Reduced bundle size | Significantly less code and fewer dependencies compared to the Redux ecosystem |
| Automatic cache invalidation | A single invalidateQueries call refreshes all related queries |
| Type safety | The queryOptions factory lets you manage query keys and data types in one place |
| Instant UX | Optimistic updates make users unable to perceive network latency |
| Developer tools | React Query DevTools lets you visualize cache state in real time |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Concurrent mutation race conditions | When two mutations run simultaneously, a refetch can overwrite optimistic state | Use setMutationDefaults + isMutating check so only the last mutation triggers revalidation |
| Rollback complexity | Implementing accurate rollbacks in nested data structures can be difficult | Use Immer middleware, design your snapshot strategy upfront |
| Dual-source-of-truth risk | Easy to accidentally copy server data into Zustand | Prevent with team coding conventions + code review |
| Temporary ID management | Easy to forget replacing temp-xxx IDs with server IDs |
Logic to swap in server response IDs in onSuccess is mandatory |
| Learning curve | Understanding the concepts and responsibility boundaries of both libraries takes time | Reading the official docs alongside TkDodo's blog helps |
What is a Race Condition? A situation where two or more async operations run concurrently and the outcome depends on execution order. In optimistic updates, it typically manifests as the first mutation's refetch overwriting the second mutation's optimistic cache.
The Most Common Mistakes in Production
-
Forgetting
cancelQueriesinonMutate: A background refetch immediately overwrites your optimistic update, causing a brief UI flicker. Always callawait queryClient.cancelQueries()at the start ofonMutate. -
Copying API response data into Zustand: TanStack Query is already caching that server data. Keeping a copy in Zustand makes it hard to predict when and where the two copies will diverge. Keep Zustand for pure UI state only — modals, selections, filters.
-
Not replacing temporary IDs of optimistically created items with server IDs: If temporary IDs like
temp-1234persist after the server responds, subsequent update and delete mutations will fail. As shown in Example 1, the logic to replace cache entries with server response IDs inonSuccessis mandatory.
Closing Thoughts
TanStack Query for server state, Zustand for UI state — following just this one principle eliminates half of all state management bugs.
The reason teams migrating from Apollo Client to this architecture report meaningful gains in bundle size and initial load time is simple. By shedding a heavy cache layer and clearly dividing responsibilities between the two libraries, you can immediately answer the question "where should this data come from right now?" That clarity ultimately raises both development velocity and codebase stability at the same time.
Three steps you can start with right now:
-
Open your current Zustand store and tag each piece of state with "did this come from the server, or is it UI-only?" The server-sourced ones are candidates for migration to TanStack Query.
-
Create a
queries/directory and definequeryOptionsfactories by domain. Install withpnpm add @tanstack/react-queryand you're ready to start. -
Apply the
onMutate→onSuccess→onError→onSettledpattern to one of your most-used mutations. Pick one — to-do creation, a like button, or add-to-cart — and port the Example 1 pattern from this post directly. You'll quickly feel that the rest of your mutations are just repetitions of the same pattern.
Want to go deeper? TkDodo's Mastering Mutations in React Query and Concurrent Optimistic Updates in React Query dig into everything covered here in much greater detail. They're written with a production focus rather than a documentation focus, so they're well worth the read.
Next post: How to use TanStack Query
suspensemode together with Error Boundaries to declaratively separate loading, error, and success states in async UIs
References
- Optimistic Updates | TanStack Query v5 Official Docs
- useMutation Reference | TanStack Query v5 Official Docs
- Concurrent Optimistic Updates in React Query | TkDodo Blog
- Mastering Mutations in React Query | TkDodo Blog
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work | DEV Community
- Building Lightning-Fast UIs: Implementing Optimistic Updates with React Query and Zustand | Medium
- How We Cut 70% Bundle Size: The TanStack Query + Zustand Architecture at GLINCKER | Medium
- Why queryOptions Will Change How You Use TanStack Query | Medium
- Optimistic Updates in Tanstack Query v5 | Medium
- Redux vs TanStack Query & Zustand in 2025 | Bugra Gulculer
- Mutations | TanStack DB Official Docs
- Zustand Official Repository | GitHub