Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/tanstack/query/llms.txt

Use this file to discover all available pages before exploring further.

Optimistic updates provide instant feedback to users by updating the UI before the server confirms the change. This creates a more responsive experience, especially for actions that are likely to succeed.

Why Optimistic Updates?

Optimistic updates improve perceived performance by:
  • Providing instant visual feedback
  • Reducing perceived latency
  • Making the app feel more responsive
  • Improving user experience for common actions
Optimistic updates assume success. Always implement proper rollback logic for when mutations fail.

UI-Based Optimistic Updates

The simplest approach: show the pending state in the UI using mutation state.
import React from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

function TodoList() {
  const queryClient = useQueryClient()
  const [text, setText] = React.useState('')
  
  const { data: todos } = useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const response = await fetch('/api/data')
      return await response.json()
    },
  })

  const addTodoMutation = useMutation({
    mutationFn: async (newTodo) => {
      const response = await fetch('/api/data', {
        method: 'POST',
        body: JSON.stringify({ text: newTodo }),
        headers: { 'Content-Type': 'application/json' },
      })
      if (!response.ok) {
        throw new Error('Failed to add todo')
      }
      return await response.json()
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <div>
      <form onSubmit={(e) => {
        e.preventDefault()
        setText('')
        addTodoMutation.mutate(text)
      }}>
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
        />
        <button disabled={addTodoMutation.isPending}>Add</button>
      </form>
      
      <ul>
        {todos?.items.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
        
        {/* Show pending item with visual indicator */}
        {addTodoMutation.isPending && (
          <li style={{ opacity: 0.5 }}>
            {addTodoMutation.variables}
          </li>
        )}
        
        {/* Show failed item with retry option */}
        {addTodoMutation.isError && (
          <li style={{ color: 'red' }}>
            {addTodoMutation.variables}
            <button onClick={() => addTodoMutation.mutate(addTodoMutation.variables)}>
              Retry
            </button>
          </li>
        )}
      </ul>
      
      {todos?.isFetching && <div>Updating in background...</div>}
    </div>
  )
}
UI-based optimistic updates are simpler to implement and don’t require rollback logic since they don’t modify the cache.

Cache-Based Optimistic Updates

For more complex scenarios, update the cache directly using the onMutate callback:
const addTodoMutation = useMutation({
  mutationFn: async (newTodo) => {
    const response = await fetch('/api/data', {
      method: 'POST',
      body: JSON.stringify({ text: newTodo }),
      headers: { 'Content-Type': 'application/json' },
    })
    return await response.json()
  },
  
  // When mutate is called:
  onMutate: async (newTodo, context) => {
    setText('')
    
    // Cancel any outgoing refetches
    // (so they don't overwrite our optimistic update)
    await context.client.cancelQueries({ queryKey: ['todos'] })

    // Snapshot the previous value
    const previousTodos = context.client.getQueryData(['todos'])

    // Optimistically update to the new value
    if (previousTodos) {
      context.client.setQueryData(['todos'], {
        ...previousTodos,
        items: [
          ...previousTodos.items,
          { id: Math.random().toString(), text: newTodo },
        ],
      })
    }

    // Return context object with the snapshotted value
    return { previousTodos }
  },
  
  // If the mutation fails,
  // use the context returned from onMutate to roll back
  onError: (err, variables, onMutateResult, context) => {
    if (onMutateResult?.previousTodos) {
      context.client.setQueryData(['todos'], onMutateResult.previousTodos)
    }
  },
  
  // Always refetch after error or success:
  onSettled: (data, error, variables, onMutateResult, context) => {
    context.client.invalidateQueries({ queryKey: ['todos'] })
  },
})
1

Cancel outgoing queries

Cancel any in-flight refetches so they don’t overwrite your optimistic update.
2

Snapshot previous state

Save the current cache state for rollback if the mutation fails.
3

Update the cache

Optimistically update the cache with the expected result.
4

Return context

Return an object containing the snapshot for use in error/success callbacks.
5

Handle errors

Roll back to the previous state if the mutation fails.
6

Refetch on settle

Always invalidate to ensure cache consistency with the server.

Update Operations

Optimistically update existing items:
const updateTodoMutation = useMutation({
  mutationFn: async ({ id, text }) => {
    const response = await fetch(`/api/todos/${id}`, {
      method: 'PUT',
      body: JSON.stringify({ text }),
      headers: { 'Content-Type': 'application/json' },
    })
    return await response.json()
  },
  
  onMutate: async (updatedTodo, context) => {
    await context.client.cancelQueries({ queryKey: ['todos'] })
    
    const previousTodos = context.client.getQueryData(['todos'])
    
    context.client.setQueryData(['todos'], (old) => ({
      ...old,
      items: old.items.map((todo) =>
        todo.id === updatedTodo.id
          ? { ...todo, text: updatedTodo.text }
          : todo
      ),
    }))
    
    return { previousTodos }
  },
  
  onError: (err, variables, onMutateResult, context) => {
    context.client.setQueryData(['todos'], onMutateResult.previousTodos)
  },
  
  onSettled: (data, error, variables, onMutateResult, context) => {
    context.client.invalidateQueries({ queryKey: ['todos'] })
  },
})

Delete Operations

Optimistically remove items:
const deleteTodoMutation = useMutation({
  mutationFn: async (todoId) => {
    await fetch(`/api/todos/${todoId}`, { method: 'DELETE' })
  },
  
  onMutate: async (todoId, context) => {
    await context.client.cancelQueries({ queryKey: ['todos'] })
    
    const previousTodos = context.client.getQueryData(['todos'])
    
    context.client.setQueryData(['todos'], (old) => ({
      ...old,
      items: old.items.filter((todo) => todo.id !== todoId),
    }))
    
    return { previousTodos }
  },
  
  onError: (err, variables, onMutateResult, context) => {
    context.client.setQueryData(['todos'], onMutateResult.previousTodos)
  },
  
  onSettled: (data, error, variables, onMutateResult, context) => {
    context.client.invalidateQueries({ queryKey: ['todos'] })
  },
})

Multiple Query Updates

Update multiple related queries optimistically:
const updateUserMutation = useMutation({
  mutationFn: updateUser,
  
  onMutate: async (updatedUser, context) => {
    // Cancel all user-related queries
    await context.client.cancelQueries({ queryKey: ['users'] })
    await context.client.cancelQueries({ queryKey: ['user', updatedUser.id] })
    
    // Snapshot previous values
    const previousUsers = context.client.getQueryData(['users'])
    const previousUser = context.client.getQueryData(['user', updatedUser.id])
    
    // Update users list
    context.client.setQueryData(['users'], (old) =>
      old.map((user) => 
        user.id === updatedUser.id ? { ...user, ...updatedUser } : user
      )
    )
    
    // Update individual user query
    context.client.setQueryData(['user', updatedUser.id], (old) => ({
      ...old,
      ...updatedUser,
    }))
    
    return { previousUsers, previousUser }
  },
  
  onError: (err, variables, onMutateResult, context) => {
    if (onMutateResult?.previousUsers) {
      context.client.setQueryData(['users'], onMutateResult.previousUsers)
    }
    if (onMutateResult?.previousUser) {
      context.client.setQueryData(
        ['user', variables.id],
        onMutateResult.previousUser
      )
    }
  },
  
  onSettled: (data, error, variables, onMutateResult, context) => {
    context.client.invalidateQueries({ queryKey: ['users'] })
    context.client.invalidateQueries({ queryKey: ['user', variables.id] })
  },
})

Using Response Data

Update the cache with the server response:
const addTodoMutation = useMutation({
  mutationFn: createTodo,
  
  onMutate: async (newTodo, context) => {
    await context.client.cancelQueries({ queryKey: ['todos'] })
    const previousTodos = context.client.getQueryData(['todos'])
    
    // Optimistic update with temporary ID
    context.client.setQueryData(['todos'], (old) => [
      ...old,
      { ...newTodo, id: 'temp-id', status: 'pending' },
    ])
    
    return { previousTodos }
  },
  
  onSuccess: (serverTodo, variables, onMutateResult, context) => {
    // Replace optimistic item with server response
    context.client.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === 'temp-id' ? serverTodo : todo
      )
    )
  },
  
  onError: (err, variables, onMutateResult, context) => {
    context.client.setQueryData(['todos'], onMutateResult.previousTodos)
  },
})
When using optimistic updates, you don’t always need to invalidate in onSuccess if you update the cache with the server response.

Best Practices

Choose the Right Strategy

  • UI-based: Simple additions, low risk of conflicts
  • Cache-based: Complex updates, multiple related queries

Handle Edge Cases

onMutate: async (newTodo, context) => {
  await context.client.cancelQueries({ queryKey: ['todos'] })
  
  const previousTodos = context.client.getQueryData(['todos'])
  
  // Guard against undefined cache
  if (previousTodos) {
    context.client.setQueryData(['todos'], {
      ...previousTodos,
      items: [...previousTodos.items, newTodo],
    })
  }
  
  return { previousTodos }
},

Provide Visual Feedback

<li
  key={todo.id}
  style={{
    opacity: todo.id.startsWith('temp-') ? 0.5 : 1,
    transition: 'opacity 0.2s',
  }}
>
  {todo.text}
  {todo.id.startsWith('temp-') && <Spinner />}
</li>

Always Use onSettled

onSettled: (data, error, variables, onMutateResult, context) => {
  // Refetch to ensure consistency, regardless of success or failure
  context.client.invalidateQueries({ queryKey: ['todos'] })
},
Never skip the onSettled callback when using cache-based optimistic updates. It’s your safety net for ensuring cache consistency.