
Optimistic UI Updates Are Harder Than They Look
I added optimistic UI to a todo app once in about fifteen minutes. Then I spent three days untangling it in a production feature that had error states, cache invalidation across multiple queries, and users who clicked things twice really fast.
The gap between "optimistic update in a tutorial" and "optimistic update in real software" is wider than it looks. Here's what I actually learned.
Why You Want This at All
The basic idea is simple: when a user takes an action, update the UI immediately instead of waiting for the server to confirm it. If the server rejects the action, roll back. This makes your app feel instant, and instant feels trustworthy.
The naive approach is just to call your state setter immediately:
typescript// Don't do this const handleLike = async (postId: string) => { setLiked(true) // immediate await likePost(postId) // actual network call }
The problem is that setLiked is local. If this component unmounts, if the data is used elsewhere, if you're caching server state anywhere — this breaks. You need the optimistic state to live where the truth lives.
The React Query Way
React Query's useMutation has onMutate, onError, and onSettled for exactly this:
typescriptconst likeMutation = useMutation({ mutationFn: (postId: string) => api.post(`/posts/${postId}/like`), onMutate: async (postId) => { // Cancel in-flight queries so they don't overwrite our optimistic update await queryClient.cancelQueries({ queryKey: ['post', postId] }) // Snapshot the previous value const previousPost = queryClient.getQueryData(['post', postId]) // Optimistically update the cache queryClient.setQueryData(['post', postId], (old: Post) => ({ ...old, likeCount: old.likeCount + 1, likedByUser: true, })) // Return context so we can rollback return { previousPost } }, onError: (err, postId, context) => { // Rollback on failure queryClient.setQueryData(['post', postId], context?.previousPost) }, onSettled: (data, error, postId) => { // Always refetch to sync with server truth queryClient.invalidateQueries({ queryKey: ['post', postId] }) }, })
This is the happy path. Three callbacks, cache snapshot, rollback, invalidate. Clean.
The Part the Docs Skip
Lists Are Complicated
The example above works for a single item query. Lists are messier. If you have ['posts'] in the cache and you're adding a new post optimistically, you need to:
- Know the shape of your list (flat array? paginated? infinite?)
- Generate a temporary ID for the new item
- Handle the case where the real item comes back with a different ID
Here's what adding a comment looks like when your posts list is paginated:
typescriptonMutate: async (newComment) => { await queryClient.cancelQueries({ queryKey: ['comments', postId] }) const previousComments = queryClient.getQueryData([ 'comments', postId, ]) // For infinite queries, data is shaped differently queryClient.setQueryData( ['comments', postId], (old: InfiniteData<CommentsPage>) => { if (!old) return old const tempComment: Comment = { ...newComment, id: `temp-${Date.now()}`, // will be replaced on settle createdAt: new Date().toISOString(), author: currentUser, isPending: true, // custom flag for pending styles } return { ...old, pages: old.pages.map((page, i) => i === 0 ? { ...page, comments: [tempComment, ...page.comments] } : page ), } } ) return { previousComments } },
The isPending flag there is useful you can style pending items differently (lighter opacity, no interactions) so the user understands the state.
The Double-Click Problem
Users click things twice. Especially on mobile. If your mutation fires twice before the first settles, your onMutate runs twice, and now you have two optimistic updates and two pending rollback contexts.
The cleanest fix is to disable the trigger after the first click:
typescript<button onClick={() => likeMutation.mutate(postId)} disabled={likeMutation.isPending} > {likeMutation.isPending ? 'Liking...' : 'Like'} </button>
But sometimes you can't disable think of a drag-and-drop reorder where you want each move to feel immediate. In that case, you need to debounce or batch mutations, which is a separate can of worms.
When the Rollback Feels Worse Than No Optimism
Here's the thing nobody says: sometimes the rollback is more jarring than just showing a loading state.
Imagine a user reorders a list, the mutation fails, and suddenly everything snaps back. That's disorienting. They don't know if their action was saved or not, and they watched the UI lie to them.
For write operations where failure is uncommon, optimistic updates are great. For operations where failure is plausible (form submissions with validation that the server does, file uploads that might hit size limits), you might be better off showing a spinner and getting real confirmation before updating the UI.
This isn't a technical rule. It's a UX judgment call.
The Pattern I Actually Ship
typescriptfunction useOptimisticMutation<TData, TVariables>( mutationFn: (vars: TVariables) => Promise<TData>, queryKey: QueryKey, optimisticUpdate: (old: TData, vars: TVariables) => TData ) { const queryClient = useQueryClient() return useMutation({ mutationFn, onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey }) const previous = queryClient.getQueryData<TData>(queryKey) queryClient.setQueryData<TData>(queryKey, (old) => old ? optimisticUpdate(old, variables) : old ) return { previous } }, onError: (_err, _vars, context) => { if (context?.previous !== undefined) { queryClient.setQueryData(queryKey, context.previous) } }, onSettled: () => { queryClient.invalidateQueries({ queryKey }) }, }) }
Wrapping the pattern means you write the snapshot/rollback logic once and only worry about the actual optimistic transform per use case.
Usage:
typescriptconst updateQuantity = useOptimisticMutation( (vars: { itemId: string; quantity: number }) => api.put(`/cart/${vars.itemId}`, { quantity: vars.quantity }), ['cart'], (old, vars) => ({ ...old, items: old.items.map((item) => item.id === vars.itemId ? { ...item, quantity: vars.quantity } : item ), }) )
What I'd Tell Myself Earlier
Start pessimistic. Ship the loading spinners. Then profile UX and add optimism selectively to the interactions where the wait is genuinely annoying. Not every mutation needs it.
And when you do add it: test the error path. Deliberately make your dev server return 500s and watch what happens. The rollback behavior is what makes or breaks the experience.