617 lines
16 KiB
Markdown
617 lines
16 KiB
Markdown
# 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
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
1. **Always Cancel Queries**
|
|
```tsx
|
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
|
```
|
|
Prevents race conditions between optimistic update and ongoing fetches.
|
|
|
|
2. **Always Return Context**
|
|
```tsx
|
|
onMutate: async (variables) => {
|
|
const previousData = queryClient.getQueryData(['todos']);
|
|
// ... update
|
|
return { previousData }; // Critical for rollback
|
|
}
|
|
```
|
|
|
|
3. **Always Handle Errors**
|
|
```tsx
|
|
onError: (err, variables, context) => {
|
|
queryClient.setQueryData(['todos'], context.previousData);
|
|
}
|
|
```
|
|
|
|
4. **Use onSettled for Refetch**
|
|
```tsx
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
}
|
|
```
|
|
Ensures data stays in sync with server.
|
|
|
|
5. **Show Visual Feedback**
|
|
- Add loading/pending states to optimistically updated items
|
|
- Show error messages on failure
|
|
- Provide undo functionality where appropriate
|
|
|
|
6. **Handle Multiple Related Queries**
|
|
- Update all queries that display the same data
|
|
- Rollback all queries on error
|
|
|
|
7. **Consider Using Temporary IDs**
|
|
- For created items, use temp IDs until server responds
|
|
- Replace with server IDs on success
|
|
|
|
8. **Test Error Cases**
|
|
- Verify rollback works correctly
|
|
- Test network failures
|
|
- Test validation errors from server
|