00
:
00
:
00
:
00
Corso SEO AI - Usa SEOEMAIL al checkout per il 30% di sconto

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] })
    },
  })
}