Optimistic Updates
Cos’e’ un Optimistic Update
Un optimistic update aggiorna la UI immediatamente, prima che il server confermi l’operazione. Se la richiesta fallisce, si esegue un rollback allo stato precedente.
Il pattern si basa su tre callback della mutation:
- onMutate: Eseguito prima della mutation. Qui si aggiorna la cache ottimisticamente.
- onError: Eseguito se la mutation fallisce. Qui si fa il rollback.
- onSettled: Eseguito sempre (successo o errore). Qui si invalida per sincronizzare col server.
Pattern Base
import { useMutation, useQueryClient } from '@tanstack/react-query'
interface Todo {
id: number
title: string
completed: boolean
}
function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !todo.completed }),
}).then((res) => res.json()),
onMutate: async (updatedTodo) => {
// 1. Cancella query in corso per evitare sovrascritture
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Salva lo stato precedente per il rollback
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// 3. Aggiorna la cache ottimisticamente
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === updatedTodo.id
? { ...todo, completed: !todo.completed }
: todo
)
)
// 4. Ritorna il contesto con lo stato precedente
return { previousTodos }
},
onError: (_error, _variables, context) => {
// Rollback allo stato precedente in caso di errore
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
// Invalida per sincronizzare con il server
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
Esempio CRUD Completo
Ecco un esempio completo con Create, Update e Delete, tutti con optimistic updates:
Aggiungere un Todo (Create)
function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newTodo: { title: string }) =>
fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
}).then((res) => res.json()),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Aggiungi il nuovo todo con un ID temporaneo
const optimisticTodo: Todo = {
id: Date.now(), // ID temporaneo
title: newTodo.title,
completed: false,
}
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old ? [...old, optimisticTodo] : [optimisticTodo]
)
return { previousTodos }
},
onError: (_err, _vars, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
Eliminare un Todo (Delete)
function useDeleteTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (todoId: number) =>
fetch(`/api/todos/${todoId}`, { method: 'DELETE' }),
onMutate: async (todoId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Rimuovi dalla cache immediatamente
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.filter((todo) => todo.id !== todoId)
)
return { previousTodos }
},
onError: (_err, _vars, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
Componente TodoList Completo
function TodoList() {
const { data: todos, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
const addMutation = useAddTodo()
const toggleMutation = useToggleTodo()
const deleteMutation = useDeleteTodo()
const [newTitle, setNewTitle] = useState('')
if (isPending) return <p>Caricamento...</p>
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
addMutation.mutate({ title: newTitle })
setNewTitle('')
}}
>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Nuovo todo..."
/>
<button type="submit" disabled={addMutation.isPending}>
Aggiungi
</button>
</form>
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate(todo)}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.title}
</span>
<button onClick={() => deleteMutation.mutate(todo.id)}>
Elimina
</button>
</li>
))}
</ul>
</div>
)
}
Optimistic Update su Singolo Elemento
Quando la query del dettaglio e la query della lista sono separate:
function useUpdatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { id: number; title: string; body: string }) =>
fetch(`/api/posts/${data.id}`, {
method: 'PUT',
body: JSON.stringify(data),
}).then((res) => res.json()),
onMutate: async (updatedPost) => {
// Cancella sia la lista che il dettaglio
await queryClient.cancelQueries({ queryKey: ['posts'] })
await queryClient.cancelQueries({ queryKey: ['post', updatedPost.id] })
const previousPost = queryClient.getQueryData<Post>(['post', updatedPost.id])
const previousPosts = queryClient.getQueryData<Post[]>(['posts'])
// Aggiorna il dettaglio
queryClient.setQueryData(['post', updatedPost.id], updatedPost)
// Aggiorna anche la lista
queryClient.setQueryData<Post[]>(['posts'], (old) =>
old?.map((p) => (p.id === updatedPost.id ? { ...p, ...updatedPost } : p))
)
return { previousPost, previousPosts }
},
onError: (_err, variables, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['post', variables.id], context.previousPost)
}
if (context?.previousPosts) {
queryClient.setQueryData(['posts'], context.previousPosts)
}
},
onSettled: (_data, _err, variables) => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
queryClient.invalidateQueries({ queryKey: ['post', variables.id] })
},
})
}