Initial commit
This commit is contained in:
234
templates/use-mutation-optimistic.tsx
Normal file
234
templates/use-mutation-optimistic.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
// src/hooks/useOptimisticTodoMutations.ts
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Todo } from './useTodos'
|
||||
|
||||
/**
|
||||
* Optimistic Update Pattern
|
||||
*
|
||||
* Updates UI immediately before server responds, then:
|
||||
* - On success: Keep the optimistic update
|
||||
* - On error: Roll back to previous state
|
||||
*
|
||||
* Best for:
|
||||
* - Low-risk actions (toggle, like, favorite)
|
||||
* - Frequently used actions (better UX with instant feedback)
|
||||
*
|
||||
* Avoid for:
|
||||
* - Critical operations (payments, account changes)
|
||||
* - Complex validations (server might reject)
|
||||
*/
|
||||
|
||||
type AddTodoInput = {
|
||||
title: string
|
||||
}
|
||||
|
||||
type UpdateTodoInput = {
|
||||
id: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic Add Todo
|
||||
*
|
||||
* Immediately shows new todo in UI, then confirms with server
|
||||
*/
|
||||
export function useOptimisticAddTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (newTodo: AddTodoInput) => {
|
||||
const response = await fetch(
|
||||
'https://jsonplaceholder.typicode.com/todos',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...newTodo, userId: 1, completed: false }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to add todo')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Before mutation runs
|
||||
onMutate: async (newTodo) => {
|
||||
// Cancel outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
// Snapshot current value
|
||||
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
|
||||
|
||||
// Optimistically update cache
|
||||
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
|
||||
...old,
|
||||
{
|
||||
id: Date.now(), // Temporary ID
|
||||
...newTodo,
|
||||
completed: false,
|
||||
userId: 1,
|
||||
},
|
||||
])
|
||||
|
||||
// Return context with snapshot (used for rollback)
|
||||
return { previousTodos }
|
||||
},
|
||||
|
||||
// If mutation fails, rollback using context
|
||||
onError: (err, newTodo, context) => {
|
||||
console.error('Failed to add todo:', err)
|
||||
|
||||
// Restore previous state
|
||||
if (context?.previousTodos) {
|
||||
queryClient.setQueryData(['todos'], context.previousTodos)
|
||||
}
|
||||
},
|
||||
|
||||
// Always refetch after mutation settles (success or error)
|
||||
// Ensures cache matches server state
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic Update Todo
|
||||
*
|
||||
* Immediately toggles todo in UI, confirms with server
|
||||
*/
|
||||
export function useOptimisticUpdateTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, completed }: UpdateTodoInput) => {
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ completed }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update todo')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
onMutate: async ({ id, completed }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
// Snapshot
|
||||
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
|
||||
old.map((todo) =>
|
||||
todo.id === id ? { ...todo, completed } : todo
|
||||
)
|
||||
)
|
||||
|
||||
return { previousTodos }
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
console.error('Failed to update todo:', err)
|
||||
if (context?.previousTodos) {
|
||||
queryClient.setQueryData(['todos'], context.previousTodos)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: (data, error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic Delete Todo
|
||||
*
|
||||
* Immediately removes todo from UI, confirms with server
|
||||
*/
|
||||
export function useOptimisticDeleteTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete todo')
|
||||
},
|
||||
|
||||
onMutate: async (deletedId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
|
||||
|
||||
// Optimistically remove from cache
|
||||
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
|
||||
old.filter((todo) => todo.id !== deletedId)
|
||||
)
|
||||
|
||||
return { previousTodos }
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
console.error('Failed to delete todo:', err)
|
||||
if (context?.previousTodos) {
|
||||
queryClient.setQueryData(['todos'], context.previousTodos)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Component usage example:
|
||||
*/
|
||||
export function OptimisticTodoItem({ todo }: { todo: Todo }) {
|
||||
const { mutate: updateTodo, isPending: isUpdating } = useOptimisticUpdateTodo()
|
||||
const { mutate: deleteTodo, isPending: isDeleting } = useOptimisticDeleteTodo()
|
||||
|
||||
return (
|
||||
<li style={{ opacity: isUpdating || isDeleting ? 0.5 : 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.completed}
|
||||
onChange={(e) => updateTodo({ id: todo.id, completed: e.target.checked })}
|
||||
disabled={isUpdating || isDeleting}
|
||||
/>
|
||||
<span>{todo.title}</span>
|
||||
<button
|
||||
onClick={() => deleteTodo(todo.id)}
|
||||
disabled={isUpdating || isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key patterns:
|
||||
*
|
||||
* 1. onMutate: Cancel queries, snapshot state, update cache optimistically
|
||||
* 2. onError: Rollback using context
|
||||
* 3. onSettled: Refetch to ensure cache matches server (always runs)
|
||||
* 4. cancelQueries: Prevent race conditions
|
||||
* 5. Return context from onMutate: Available in onError and onSettled
|
||||
*
|
||||
* Trade-offs:
|
||||
* ✅ Instant UI feedback (feels faster)
|
||||
* ✅ Better UX for common actions
|
||||
* ❌ More complex code
|
||||
* ❌ Risk of inconsistent state if not handled correctly
|
||||
* ❌ Not suitable for critical operations
|
||||
*/
|
||||
Reference in New Issue
Block a user