The default mutation pattern is broken. User clicks "Delete", a loading spinner spins for 800ms while you make a POST to the backend, the item disappears, the user wonders if it worked. This delay is artificial. You've already rendered the app, you know what the mutation should do, so why make the user wait? Optimistic UI updates flip the order: show the change immediately, confirm with the server asynchronously, revert only if something breaks. Users see instant feedback. Network latency vanishes. The UX feels responsive, even on slow connections. This is a solved problem in TanStack Query—ship optimistic mutations with useMutation + onMutate + onError callbacks. Every Velocity dashboard uses this pattern for deletes, toggles, reorders, and status changes. It's non-negotiable.
The Problem: Mutations That Block the UI
Here's how most engineers write mutations:
// src/hooks/useDeleteRoute.ts — slow
import { useMutation } from '@tanstack/react-query';
export function useDeleteRoute() {
return useMutation({
mutationFn: async (routeId) => {
const res = await fetch(`/api/routes/${routeId}`, {
method: 'DELETE',
});
return res.json();
},
});
}
In a component:
// src/components/RouteItem.tsx
export function RouteItem({ route }) {
const deleteRoute = useDeleteRoute();
return (
<div>
<span>{route.name}</span>
<button
disabled={deleteRoute.isPending}
onClick={() => deleteRoute.mutate(route.id)}
>
{deleteRoute.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
);
}
User clicks Delete. The button grays out, "Deleting..." shows, and you wait for the server. The item stays in the list until the response arrives. If the network is slow, the user sees the spinner for 2+ seconds. If they accidentally deleted it, tough luck — there's no instant undo. Even worse, the item is still in the cache until the mutation completes. Refresh the page and it's back. This is the worst UX you can ship.
The Pattern: Optimistic Updates in Three Callbacks
TanStack Query mutations support three lifecycle callbacks: onMutate, onError, and onSuccess. Use them:
// src/hooks/useDeleteRoute.ts — optimistic
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useDeleteRoute() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (routeId) => {
const res = await fetch(`/api/routes/${routeId}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete');
return res.json();
},
onMutate: async (routeId) => {
// Cancel pending queries so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['routes'] });
// Snapshot the current cache
const previousRoutes = queryClient.getQueryData(['routes']);
// Update cache optimistically
queryClient.setQueryData(['routes'], (old) =>
old?.filter(r => r.id !== routeId)
);
// Return context for rollback
return { previousRoutes };
},
onError: (err, routeId, context) => {
// Revert to snapshot if mutation fails
queryClient.setQueryData(['routes'], context.previousRoutes);
},
onSuccess: () => {
// Refetch to sync with server (optional, cache is already correct)
queryClient.invalidateQueries({ queryKey: ['routes'] });
},
});
}
Now in the component, the item disappears instantly:
// src/components/RouteItem.tsx
export function RouteItem({ route }) {
const deleteRoute = useDeleteRoute();
return (
<div>
<span>{route.name}</span>
<button
onClick={() => deleteRoute.mutate(route.id)}
>
Delete
</button>
</div>
);
}
User clicks Delete. The item vanishes immediately. In the background, the mutation hits the server. If it succeeds, great — the item is gone. If it fails (permission error, validation error, network timeout), the item appears again silently. The user never saw the error because the item was never truly deleted. They might refresh the page to double-check, but the item reappears, so no confusion. This is UX magic.
Three Real Examples: Delete, Toggle, Reorder
Example 1: Optimistic Delete
Already covered above, but the key steps are: snapshot the cache in onMutate, filter the item out, return the snapshot for onError. If the delete fails, the snapshot reverts the cache and the item reappears. Users never see inconsistent state.
Example 2: Optimistic Toggle (Checkbox / Switch)
Toggle a boolean field optimistically:
// src/hooks/useToggleRouteActive.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useToggleRouteActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ routeId, newValue }) => {
const res = await fetch(`/api/routes/${routeId}`, {
method: 'PATCH',
body: JSON.stringify({ is_active: newValue }),
});
return res.json();
},
onMutate: async ({ routeId, newValue }) => {
await queryClient.cancelQueries({ queryKey: ['routes'] });
const previousRoutes = queryClient.getQueryData(['routes']);
queryClient.setQueryData(['routes'], (old) =>
old?.map(r =>
r.id === routeId ? { ...r, is_active: newValue } : r
)
);
return { previousRoutes };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['routes'], context.previousRoutes);
},
});
}
In a component:
// src/components/RouteToggle.tsx
export function RouteToggle({ route }) {
const toggleActive = useToggleRouteActive();
return (
<input
type="checkbox"
checked={route.is_active}
onChange={(e) =>
toggleActive.mutate({
routeId: route.id,
newValue: e.target.checked,
})
}
/>
);
}
Checkbox changes instantly. The mutation syncs in the background. If it fails, the checkbox reverts. Users never stare at a loading state for a toggle.
Example 3: Optimistic Reorder (Drag-and-Drop)
Reordering is the most complex case because you're mutating order for multiple items:
// src/hooks/useReorderRoutes.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useReorderRoutes() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newOrder) => {
const res = await fetch('/api/routes/reorder', {
method: 'POST',
body: JSON.stringify({ order: newOrder }),
});
return res.json();
},
onMutate: async (newOrder) => {
await queryClient.cancelQueries({ queryKey: ['routes'] });
const previousRoutes = queryClient.getQueryData(['routes']);
// newOrder is array of { id, order_index }
queryClient.setQueryData(['routes'], (old) => {
const orderMap = new Map(newOrder.map(o => [o.id, o.order_index]));
return old
?.map(r => ({ ...r, order_index: orderMap.get(r.id) ?? r.order_index }))
.sort((a, b) => a.order_index - b.order_index);
});
return { previousRoutes };
},
onError: (err, newOrder, context) => {
queryClient.setQueryData(['routes'], context.previousRoutes);
},
});
}
User drags a route. The list reorders instantly. The mutation batches all the order changes and sends them to the server. If the server rejects (due to concurrent reorders by another user), the list snaps back to the previous order. No jank, no confusion.
Conflict Resolution: What Happens When Two Users Mutate the Same Data
Here's the hard case: User A reorders routes, User B toggles a route status, both mutations race. In a naive optimistic system, one mutation might overwrite the other's changes. TanStack Query's queryClient.invalidateQueries() in onSuccess solves this: after the mutation succeeds, refetch the query to sync with the server. The server is the source of truth. Your UI always reflects it.
For high-frequency dashboards (Rebuild Relief's location-sorting tool with 20+ concurrent users), combine optimistic updates with Supabase realtime. When User B's mutation broadcasts via Realtime, User A's query refetches and merges both changes. Everyone's in sync.
Six FAQs
Should I always use optimistic updates?
Yes, for fast mutations (toggles, deletes, reorders). Skip for slow operations (image uploads, large file processing) where the latency is unavoidable anyway. Optimistic updates shine when latency is artificial.
What if the server validation fails?
The onError callback reverts the cache. The user sees the item reappear or the toggle flip back. You can also show a toast: onError: (err) => toast.error(err.message).
Can I show a loading state during optimistic updates?
Yes. The mutation is still pending while the server responds. Use isPending to gray out the button or show a subtle indicator. The data is already updated optimistically, so the UI is responsive even while waiting.
How do I handle partial failures (some items succeed, others fail)?
Batch mutations carefully. If reordering 10 routes and only 5 succeed, the server should return the successful state, and your onSuccess refetch merges it. Or use invalidateQueries to resync everything after the batch completes.
Does optimistic update work with Supabase?
Yes. Call Supabase's .update() or .delete() inside mutationFn, use the same onMutate + onError pattern, and optionally subscribe to Realtime for live conflict resolution. See the TanStack Query post for Realtime examples.
Can I test optimistic mutations?
Absolutely. Wrap your test in QueryClientProvider, mock the mutationFn, assert the cache state in onMutate, and verify rollback in onError. TanStack Query's test utilities handle this. The pattern is deterministic and easy to verify.
The Bottom Line
Optimistic UI updates are the fastest way to ship responsive mutations. Show the change instantly, sync with the server asynchronously, revert only if something breaks. TanStack Query's useMutation with onMutate, onError, and onSuccess callbacks handle all the complexity. For dashboards with dozens of concurrent users, this pattern is mandatory. Combine it with Aidxn's full stack (Supabase, Realtime, TanStack Query) and your app feels instant. Ship it.