What is an Optimistic UI Update?
Normally, when a user performs an action (like posting a comment), the flow is:
- User clicks "Submit".
- Show a loading spinner.
- Wait for the server to respond with "Success".
- Remove the spinner and show the new comment in the list.
This wait, even if it's just a few hundred milliseconds, can make an application feel sluggish.
An optimistic update flips this on its head. It assumes the server request will succeed and updates the UI immediately, before the server has even responded.
Analogy: When you send a message in a chat app, it appears in your chat window instantly, often with a small "sending..." icon. The app is optimistically assuming the message will go through. If it fails, it then shows an error.
The Optimistic Update Flow
- User performs an action.
- Immediately update the client-side state/cache with the expected new state. The UI re-renders instantly.
- Send the actual API request to the server in the background.
- On Success: The server confirms the change. The client state is already correct, so we might just need to refetch the data to get the final, server-confirmed version (e.g., an item with a real ID).
- On Error: The server returns an error. We must rollback the UI change and show an error message to the user.
Implementing Optimistic Updates with TanStack Query
TanStack Query's useMutation hook provides the perfect lifecycle callbacks to implement this pattern cleanly.
- onMutate: This function runs before the mutation function. It's the ideal place to optimistically update our cache. It should return a context object (e.g., a snapshot of the previous state) that can be used for rollbacks.
- onError: This function runs if the mutation fails. It receives the context from onMutate, allowing us to rollback the UI to its previous state.
- onSettled: This function runs after the mutation either succeeds or fails. It's the perfect place to refetch the real data to ensure our UI is in sync with the server.
Code Example (Adding a Todo):
JavaScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
function TodoList() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) => axios.post('/api/todos', newTodo),
// 1. Runs before the mutation
onMutate: async (newTodo) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update to the new value
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
// Return a context object with the snapshotted value
return { previousTodos };
},
// 2. If the mutation fails, use the context returned from onMutate to roll back
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
// Show an error toast or message
},
// 3. Always refetch after error or success to be safe
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
const handleAddTodo = () => {
// We add a temporary ID for the optimistic update
const tempTodo = { id: Date.now(), title: 'New optimistic todo', isTemporary: true };
mutation.mutate(tempTodo);
};
return <button onClick={handleAddTodo}>Add Todo</button>;
}