Week 12

TanStack Query

Optimistic updates

Authentication

Data persistance

Security Fundamentals

Practice

Assignment

Frontend Track

Optimistic updates

When a user toggles a checkbox, likes a post, or reorders a list, waiting for the server to confirm the change before updating the UI feels sluggish. The user clicked something - they want to see it respond immediately.

An optimistic update means you update the UI as if the request already succeeded, then confirm or roll back once the server responds. If the server says it worked, nothing visible changes - the UI was already correct. If the server returns an error, you undo the change and show an error message.

This is not appropriate for every action. Submitting a payment, deleting an account, or sending a message are cases where the server's confirmation matters before you tell the user it worked. But for low-stakes, reversible interactions - toggles, likes, reorders - it's a genuine UX improvement.


The Pattern: onMutate, onError, onSettled

TanStack Query's useMutation has three lifecycle callbacks that make optimistic updates clean to implement:

Callback When it runs What to do
onMutate Before the request fires Update the cache optimistically; return a context object with the previous state
onError When the request fails Roll back to the previous state using the context
onSettled After success or error Invalidate queries to sync with the server

Example: Toggling a Todo

Imagine a todo list where each item has a checkbox. Clicking it should feel instant.

type Todo = {
  id: number
  title: string
  completed: boolean
}

Without optimistic updates, the toggle waits for the server:

  1. User clicks - nothing happens visually
  2. Request goes out
  3. Server responds
  4. invalidateQueries fires
  5. UI updates

With optimistic updates, step 1 and step 5 happen simultaneously.

import { useMutation, useQueryClient } from '@tanstack/react-query'

function TodoItem({ todo }: { todo: Todo }) {
  const queryClient = useQueryClient()

  const toggleMutation = useMutation({
    mutationFn: (updated: Todo) =>
      fetch(`/api/todos/${updated.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: updated.completed }),
      }).then(res => res.json()),

    onMutate: async (updatedTodo) => {
      // 1. Cancel any in-flight refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // 2. Snapshot the current value so we can roll back if needed
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])

      // 3. Optimistically update the cache
      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map(t => t.id === updatedTodo.id ? updatedTodo : t) ?? []
      )

      // 4. Return the snapshot in context - onError receives this so it know how to rollback
      return { previousTodos }
    },

    onError: (_err, _updatedTodo, context) => {
      // The request failed - restore the previous state
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos)
      }
    },

    onSettled: () => {
      // Whether it succeeded or failed, sync with the server
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  function handleToggle() {
    toggleMutation.mutate({ ...todo, completed: !todo.completed })
  }

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={handleToggle}
      />
      <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
        {todo.title}
      </span>
    </li>
  )
}

Walk through what happens when the user checks the box:

  1. onMutate runs - the cache is updated immediately with completed: true, so the checkbox checks and the text gets a strikethrough before the request finishes
  2. The PATCH request goes out
  3. If the server succeeds: onSettled fires, invalidateQueries triggers a background refetch to confirm the server state matches
  4. If the server fails: onError fires, setQueryData restores previousTodos, the checkbox unchecks itself, and onSettled still fires to sync

<aside> 💡

cancelQueries in onMutate is important. Without it, a background refetch that was already in flight could complete after your optimistic update and overwrite it with the old data, briefly reverting the UI before the mutation completes.

</aside>

Rollback on Error

The rollback happens in onError. The previous state is available because onMutate returned it in the context object - TanStack Query passes that context through to onError automatically.

// error     - the thrown error from mutationFn
// variables - what you passed to mutate()
// context   - whatever onMutate returned
onError: (error, variables, context) => {
  if (context?.previousTodos) {
    queryClient.setQueryData(['todos'], context.previousTodos)
  }

  // You can also show a toast or error message here
  console.error('Toggle failed:', error.message)
},

Always type the context if you want TypeScript to know its shape. You can do this by typing the mutation:

useMutation<Todo, Error, Todo, { previousTodos: Todo[] | undefined }>({
  // ...
})

When to use

When NOT to use

<aside> ⚠️

If your server can reject the mutation for reasons you can't predict on the client (validation errors, permissions, concurrency conflicts), make sure your rollback path is always correct. A half-reverted UI is worse than a slow one.

</aside>

Additional Resources

Reading


The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with ❤️ by the HackYourFuture community · Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.