React Optimistic Updates
A TanStack Query v5 pattern that updates the UI immediately on user action, rolls back automatically on error, and revalidates in the background. Eliminates the lag between user intent and visual response in every CRUD interface.
Install
curl -fsSL https://skills.vishalvoid.com/install react-optimistic-updates | bashThe problem
Traditional CRUD flows create a loop: user acts → spinner appears → server responds → UI updates. Every step after the first is invisible latency. On a fast connection this is annoying; on a slow one it destroys the experience. Optimistic updates invert this: apply the change immediately, sync with the server in the background, and only surface errors if something actually goes wrong.
Core hook — useMutation with rollback
The three hooks that make optimistic updates safe: `onMutate` applies the change and saves a snapshot, `onError` restores from the snapshot if the server rejects it, `onSettled` always triggers a fresh revalidation so the cache stays accurate.
// hooks/use-update-item.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateItem } from '@/lib/api'
import type { Item } from '@/lib/types'
export function useUpdateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateItem,
onMutate: async (updated: Partial<Item> & { id: string }) => {
// Cancel any in-flight refetches for this query
await queryClient.cancelQueries({ queryKey: ['items'] })
// Snapshot current state for rollback
const previous = queryClient.getQueryData<Item[]>(['items'])
// Optimistically apply the update
queryClient.setQueryData<Item[]>(['items'], (old = []) =>
old.map((item) =>
item.id === updated.id ? { ...item, ...updated } : item
)
)
return { previous }
},
onError: (_err, _vars, context) => {
// Rollback to the snapshot on failure
if (context?.previous) {
queryClient.setQueryData(['items'], context.previous)
}
},
onSettled: () => {
// Always revalidate after mutation (success or failure)
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
}// hooks/use-update-item.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateItem } from '@/lib/api'
import type { Item } from '@/lib/types'
export function useUpdateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateItem,
onMutate: async (updated: Partial<Item> & { id: string }) => {
// Cancel any in-flight refetches for this query
await queryClient.cancelQueries({ queryKey: ['items'] })
// Snapshot current state for rollback
const previous = queryClient.getQueryData<Item[]>(['items'])
// Optimistically apply the update
queryClient.setQueryData<Item[]>(['items'], (old = []) =>
old.map((item) =>
item.id === updated.id ? { ...item, ...updated } : item
)
)
return { previous }
},
onError: (_err, _vars, context) => {
// Rollback to the snapshot on failure
if (context?.previous) {
queryClient.setQueryData(['items'], context.previous)
}
},
onSettled: () => {
// Always revalidate after mutation (success or failure)
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
}Applying it in a component
The component has zero awareness of loading state — it just calls `mutate` and the UI responds instantly. Error handling lives in the mutation hook, not scattered across components.
// components/item-row.tsx
'use client'
import { useUpdateItem } from '@/hooks/use-update-item'
import type { Item } from '@/lib/types'
export function ItemRow({ item }: { item: Item }) {
const { mutate } = useUpdateItem()
return (
<div className="flex items-center gap-3 py-2">
<input
type="checkbox"
checked={item.completed}
onChange={(e) =>
mutate({ id: item.id, completed: e.target.checked })
}
/>
<span
className={item.completed ? 'line-through text-muted' : ''}
>
{item.title}
</span>
</div>
)
}// components/item-row.tsx
'use client'
import { useUpdateItem } from '@/hooks/use-update-item'
import type { Item } from '@/lib/types'
export function ItemRow({ item }: { item: Item }) {
const { mutate } = useUpdateItem()
return (
<div className="flex items-center gap-3 py-2">
<input
type="checkbox"
checked={item.completed}
onChange={(e) =>
mutate({ id: item.id, completed: e.target.checked })
}
/>
<span
className={item.completed ? 'line-through text-muted' : ''}
>
{item.title}
</span>
</div>
)
}Optimistic deletes
Deletes follow the same pattern but filter out the item rather than merging properties. The item disappears from the list instantly; if the server fails, it reappears.
export function useDeleteItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteItem(id),
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: ['items'] })
const previous = queryClient.getQueryData<Item[]>(['items'])
queryClient.setQueryData<Item[]>(['items'], (old = []) =>
old.filter((item) => item.id !== id)
)
return { previous }
},
onError: (_err, _id, context) => {
if (context?.previous) {
queryClient.setQueryData(['items'], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
}export function useDeleteItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteItem(id),
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: ['items'] })
const previous = queryClient.getQueryData<Item[]>(['items'])
queryClient.setQueryData<Item[]>(['items'], (old = []) =>
old.filter((item) => item.id !== id)
)
return { previous }
},
onError: (_err, _id, context) => {
if (context?.previous) {
queryClient.setQueryData(['items'], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
}Optimistic creates with temporary IDs
For creates, generate a temporary client-side ID so the item can be placed in the list immediately. When the server responds with the real ID, `onSettled` revalidation replaces it automatically.
export function useCreateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createItem,
onMutate: async (newItem: NewItem) => {
await queryClient.cancelQueries({ queryKey: ['items'] })
const previous = queryClient.getQueryData<Item[]>(['items'])
// Use a temp ID — server will assign the real one
const optimistic: Item = {
...newItem,
id: `temp-${crypto.randomUUID()}`,
createdAt: new Date().toISOString(),
}
queryClient.setQueryData<Item[]>(['items'], (old = []) => [
optimistic,
...old,
])
return { previous }
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['items'], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
}export function useCreateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createItem,
onMutate: async (newItem: NewItem) => {
await queryClient.cancelQueries({ queryKey: ['items'] })
const previous = queryClient.getQueryData<Item[]>(['items'])
// Use a temp ID — server will assign the real one
const optimistic: Item = {
...newItem,
id: `temp-${crypto.randomUUID()}`,
createdAt: new Date().toISOString(),
}
queryClient.setQueryData<Item[]>(['items'], (old = []) => [
optimistic,
...old,
])
return { previous }
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['items'], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
}When to use — and when not to
Use optimistic updates when: toggling boolean states (complete, archived, starred), inline text edits where you know the exact new value, drag-to-reorder, list item deletion. Avoid when: the server generates data you can't predict (computed fields, database triggers, external API calls during mutation), or when concurrent edits from other users make the snapshot unreliable.
Common pitfalls
1. Forgetting `cancelQueries` — without it, an in-flight refetch can overwrite your optimistic state mid-mutation. 2. Not returning `previous` from `onMutate` — TanStack Query passes `context` to `onError` from the return value of `onMutate`; if you don't return it, rollback is impossible. 3. Mutating the cache directly — always use `setQueryData` with an immutable update, never push/splice into the array.
Related skills