16 KiB
16 KiB
Optimistic Updates
Optimistic updates allow you to update the UI immediately before a mutation completes, providing a better user experience. If the mutation fails, you can roll back to the previous state.
Basic Optimistic Update
import { useMutation, useQueryClient } from '@tanstack/react-query';
function TodoList() {
const queryClient = useQueryClient();
const toggleTodo = useMutation({
mutationFn: (todoId) => {
return fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' });
},
onMutate: async (todoId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === todoId ? { ...todo, done: !todo.done } : todo
)
);
// Return context with previous value
return { previousTodos };
},
onError: (err, todoId, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Refetch after mutation completes
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<div>
{/* render todos with toggle */}
<button onClick={() => toggleTodo.mutate(todoId)}>Toggle</button>
</div>
);
}
Mutation Lifecycle
Understanding the mutation lifecycle is crucial for optimistic updates:
const mutation = useMutation({
mutationFn: updateTodo,
// 1. Before mutation function runs
onMutate: async (variables) => {
// Cancel queries, snapshot data, optimistically update
// Return context object
return { previousData };
},
// 2. If mutation succeeds
onSuccess: (data, variables, context) => {
// Handle successful mutation
// data = mutation function response
// variables = mutation variables
// context = returned from onMutate
},
// 3. If mutation fails
onError: (error, variables, context) => {
// Rollback optimistic update
// error = error object
// context = returned from onMutate
},
// 4. Always runs after success or error
onSettled: (data, error, variables, context) => {
// Refetch to sync with server
},
});
Optimistic Update Patterns
Adding an Item
const addTodo = useMutation({
mutationFn: (newTodo) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(res => res.json());
},
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
// Add optimistic todo with temporary ID
queryClient.setQueryData(['todos'], (old) => [
...old,
{ ...newTodo, id: 'temp-' + Date.now(), status: 'pending' },
]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSuccess: (data) => {
// Replace temporary item with real server response
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id.toString().startsWith('temp-') ? data : todo
)
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Updating an Item
const updateTodo = useMutation({
mutationFn: ({ id, updates }) => {
return fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
}).then(res => res.json());
},
onMutate: async ({ id, updates }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === id ? { ...todo, ...updates } : todo
)
);
return { previousTodos };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// Usage
updateTodo.mutate({ id: 1, updates: { title: 'Updated title' } });
Deleting an Item
const deleteTodo = useMutation({
mutationFn: (todoId) => {
return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
},
onMutate: async (todoId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.filter((todo) => todo.id !== todoId)
);
return { previousTodos };
},
onError: (err, todoId, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Multiple Query Updates
Update multiple related queries optimistically:
const updateUser = useMutation({
mutationFn: ({ userId, updates }) => {
return fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
}).then(res => res.json());
},
onMutate: async ({ userId, updates }) => {
// Cancel all related queries
await queryClient.cancelQueries({ queryKey: ['users'] });
await queryClient.cancelQueries({ queryKey: ['user', userId] });
// Snapshot previous data
const previousUsers = queryClient.getQueryData(['users']);
const previousUser = queryClient.getQueryData(['user', userId]);
// Update users list
queryClient.setQueryData(['users'], (old) =>
old?.map((user) =>
user.id === userId ? { ...user, ...updates } : user
)
);
// Update individual user
queryClient.setQueryData(['user', userId], (old) => ({
...old,
...updates,
}));
return { previousUsers, previousUser };
},
onError: (err, { userId }, context) => {
// Rollback both queries
queryClient.setQueryData(['users'], context.previousUsers);
queryClient.setQueryData(['user', userId], context.previousUser);
},
onSettled: (data, error, { userId }) => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
Optimistic Updates with Infinite Queries
const addPost = useMutation({
mutationFn: (newPost) => {
return fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(res => res.json());
},
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previousPosts = queryClient.getQueryData(['posts']);
// Add to first page
queryClient.setQueryData(['posts'], (old) => {
if (!old?.pages.length) return old;
return {
...old,
pages: [
{
...old.pages[0],
posts: [
{ ...newPost, id: 'temp-' + Date.now() },
...old.pages[0].posts,
],
},
...old.pages.slice(1),
],
};
});
return { previousPosts };
},
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts'], context.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
UI Feedback During Optimistic Updates
Show Pending State
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const toggleTodo = useMutation({
mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' }),
onMutate: async (todoId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((t) =>
t.id === todoId
? { ...t, done: !t.done, isPending: true } // Mark as pending
: t
)
);
return { previousTodos };
},
onSuccess: (data, todoId) => {
// Remove pending state
queryClient.setQueryData(['todos'], (old) =>
old.map((t) =>
t.id === todoId ? { ...t, isPending: false } : t
)
);
},
onError: (err, todoId, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
});
return (
<div className={todo.isPending ? 'opacity-50' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo.mutate(todo.id)}
disabled={todo.isPending}
/>
{todo.title}
</div>
);
}
Show Error State
const [error, setError] = useState(null);
const updateTodo = useMutation({
mutationFn: updateTodoApi,
onMutate: async (updates) => {
setError(null); // Clear previous errors
// ... optimistic update
},
onError: (err, variables, context) => {
setError(err.message);
// ... rollback
},
});
return (
<div>
{error && <div className="error">{error}</div>}
{/* render todo */}
</div>
);
Advanced Patterns
Optimistic Update with Retry
const updateTodo = useMutation({
mutationFn: updateTodoApi,
retry: 3,
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === updates.id
? { ...todo, ...updates, _optimistic: true }
: todo
)
);
return { previousTodos };
},
onSuccess: (data, variables) => {
// Remove optimistic flag
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === variables.id
? { ...todo, _optimistic: false }
: todo
)
);
},
onError: (err, variables, context) => {
// Only rollback if all retries failed
if (err.retryCount >= 3) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
});
Debounced Optimistic Updates
For rapid updates like typing in a search or editing text:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useDebouncedCallback } from 'use-debounce';
function TodoTitle({ todo }) {
const queryClient = useQueryClient();
const [localTitle, setLocalTitle] = useState(todo.title);
const updateTodo = useMutation({
mutationFn: ({ id, title }) => {
return fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
}).then(res => res.json());
},
onMutate: async ({ id, title }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((t) => (t.id === id ? { ...t, title } : t))
);
return { previousTodos };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
setLocalTitle(context.previousTodos.find(t => t.id === variables.id).title);
},
});
const debouncedUpdate = useDebouncedCallback(
(id, title) => updateTodo.mutate({ id, title }),
500
);
const handleChange = (e) => {
const newTitle = e.target.value;
setLocalTitle(newTitle);
debouncedUpdate(todo.id, newTitle);
};
return <input value={localTitle} onChange={handleChange} />;
}
Optimistic Delete with Undo
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const [showUndo, setShowUndo] = useState(false);
const deleteTodo = useMutation({
mutationFn: (todoId) => {
return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
},
onMutate: async (todoId) => {
setShowUndo(true);
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.filter((t) => t.id !== todoId)
);
// Auto-hide undo after 5 seconds
setTimeout(() => setShowUndo(false), 5000);
return { previousTodos };
},
onError: (err, todoId, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
setShowUndo(false);
},
});
const handleUndo = () => {
deleteTodo.reset(); // Reset mutation state
queryClient.invalidateQueries({ queryKey: ['todos'] });
setShowUndo(false);
};
if (showUndo) {
return (
<div className="undo-banner">
Todo deleted <button onClick={handleUndo}>Undo</button>
</div>
);
}
return (
<div>
{todo.title}
<button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button>
</div>
);
}
Batch Optimistic Updates
const markAllDone = useMutation({
mutationFn: () => {
return fetch('/api/todos/mark-all-done', { method: 'POST' });
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) => ({ ...todo, done: true }))
);
return { previousTodos };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Cancel In-Flight Mutations
Cancel mutations that are no longer needed:
function QuickEdit({ todo }) {
const queryClient = useQueryClient();
const updateTodo = useMutation({
mutationFn: ({ id, title }) => {
return fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
}).then(res => res.json());
},
onMutate: async ({ id, title }) => {
// Cancel previous mutations for this todo
queryClient.cancelMutations({ mutationKey: ['updateTodo', id] });
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((t) => (t.id === id ? { ...t, title } : t))
);
return { previousTodos };
},
});
return (
<input
onChange={(e) => updateTodo.mutate({ id: todo.id, title: e.target.value })}
/>
);
}
Best Practices
-
Always Cancel Queries
await queryClient.cancelQueries({ queryKey: ['todos'] });Prevents race conditions between optimistic update and ongoing fetches.
-
Always Return Context
onMutate: async (variables) => { const previousData = queryClient.getQueryData(['todos']); // ... update return { previousData }; // Critical for rollback } -
Always Handle Errors
onError: (err, variables, context) => { queryClient.setQueryData(['todos'], context.previousData); } -
Use onSettled for Refetch
onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }Ensures data stays in sync with server.
-
Show Visual Feedback
- Add loading/pending states to optimistically updated items
- Show error messages on failure
- Provide undo functionality where appropriate
-
Handle Multiple Related Queries
- Update all queries that display the same data
- Rollback all queries on error
-
Consider Using Temporary IDs
- For created items, use temp IDs until server responds
- Replace with server IDs on success
-
Test Error Cases
- Verify rollback works correctly
- Test network failures
- Test validation errors from server