Core Patterns
Concurrent optimistic updates need per-mutation identity and scoped invalidation. A rollback or invalidation from one mutation should not erase another mutation that is still pending.
Scope related mutations
const mutation = useMutation({
mutationKey: ['items'],
mutationFn: toggleItem,
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ['items', 'detail', id] })
const previousItem = queryClient.getQueryData<Item>(['items', 'detail', id])
queryClient.setQueryData<Item>(['items', 'detail', id], (item) =>
item ? { ...item, isActive: !item.isActive } : item,
)
return { previousItem }
},
onError: (_error, variables, context) => {
queryClient.setQueryData(
['items', 'detail', variables.id],
context?.previousItem,
)
},
onSettled: () => {
if (queryClient.isMutating({ mutationKey: ['items'] }) === 1) {
return queryClient.invalidateQueries({ queryKey: ['items'] })
}
},
})
Use queryClient.isMutating() imperatively inside the callback so the count is current at the moment invalidation would run.
Render pending variables with stable keys
const pendingAdds = useMutationState<string>({
filters: { mutationKey: ['todos', 'add'], status: 'pending' },
select: (mutation) =>
`${mutation.state.submittedAt}:${mutation.state.variables}`,
})
submittedAt distinguishes multiple pending mutations with the same variables.
Common Mistakes
CRITICAL One mutation invalidation reverts another optimistic update
Wrong:
onSettled: () => queryClient.invalidateQueries({ queryKey: ['items'] })
Correct:
onSettled: () => {
if (queryClient.isMutating({ mutationKey: ['items'] }) === 1) {
return queryClient.invalidateQueries({ queryKey: ['items'] })
}
}
Skip intermediate invalidations while related optimistic mutations are still in flight.
Source: https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query
HIGH Optimistic list update ignores current filters
Wrong:
queryClient.setQueryData(['items', 'list', filters], (items) =>
items?.map((item) => (item.id === updated.id ? updated : item)),
)
Correct:
queryClient.setQueryData(['items', 'list', filters], (items) =>
items
?.map((item) => (item.id === updated.id ? updated : item))
.filter((item) => matchesFilters(item, filters)),
)
If the server would remove the updated item from the filtered list, the optimistic cache update should do the same.
Source: https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query
HIGH Pending optimistic rows share unstable keys
Wrong:
{
variables.map((title) => <li key={title}>{title}</li>)
}
Correct:
const pending = useMutationState({
filters: { mutationKey: ['todos', 'add'], status: 'pending' },
select: (mutation) => ({
variables: mutation.state.variables,
submittedAt: mutation.state.submittedAt,
}),
})
{
pending.map((todo) => <li key={todo.submittedAt}>{todo.variables.title}</li>)
}
Use mutation metadata to keep concurrent optimistic rows distinct.
Source: TanStack/query:docs/framework/react/guides/optimistic-updates.md