Files
2025-11-30 09:01:30 +08:00

16 KiB

Optimistic Updates

Optimistic updates allow you to update the UI immediately before a mutation completes, providing a better user experience. If the mutation fails, you can roll back to the previous state.

Basic Optimistic Update

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

function TodoList() {
  const queryClient = useQueryClient();

  const toggleTodo = useMutation({
    mutationFn: (todoId) => {
      return fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' });
    },
    onMutate: async (todoId) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['todos'] });

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

      // Optimistically update
      queryClient.setQueryData(['todos'], (old) =>
        old.map((todo) =>
          todo.id === todoId ? { ...todo, done: !todo.done } : todo
        )
      );

      // Return context with previous value
      return { previousTodos };
    },
    onError: (err, todoId, context) => {
      // Rollback on error
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
    onSettled: () => {
      // Refetch after mutation completes
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <div>
      {/* render todos with toggle */}
      <button onClick={() => toggleTodo.mutate(todoId)}>Toggle</button>
    </div>
  );
}

Mutation Lifecycle

Understanding the mutation lifecycle is crucial for optimistic updates:

const mutation = useMutation({
  mutationFn: updateTodo,

  // 1. Before mutation function runs
  onMutate: async (variables) => {
    // Cancel queries, snapshot data, optimistically update
    // Return context object
    return { previousData };
  },

  // 2. If mutation succeeds
  onSuccess: (data, variables, context) => {
    // Handle successful mutation
    // data = mutation function response
    // variables = mutation variables
    // context = returned from onMutate
  },

  // 3. If mutation fails
  onError: (error, variables, context) => {
    // Rollback optimistic update
    // error = error object
    // context = returned from onMutate
  },

  // 4. Always runs after success or error
  onSettled: (data, error, variables, context) => {
    // Refetch to sync with server
  },
});

Optimistic Update Patterns

Adding an Item

const addTodo = useMutation({
  mutationFn: (newTodo) => {
    return fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify(newTodo),
    }).then(res => res.json());
  },
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);

    // Add optimistic todo with temporary ID
    queryClient.setQueryData(['todos'], (old) => [
      ...old,
      { ...newTodo, id: 'temp-' + Date.now(), status: 'pending' },
    ]);

    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSuccess: (data) => {
    // Replace temporary item with real server response
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id.toString().startsWith('temp-') ? data : todo
      )
    );
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Updating an Item

const updateTodo = useMutation({
  mutationFn: ({ id, updates }) => {
    return fetch(`/api/todos/${id}`, {
      method: 'PATCH',
      body: JSON.stringify(updates),
    }).then(res => res.json());
  },
  onMutate: async ({ id, updates }) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);

    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === id ? { ...todo, ...updates } : todo
      )
    );

    return { previousTodos };
  },
  onError: (err, variables, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

// Usage
updateTodo.mutate({ id: 1, updates: { title: 'Updated title' } });

Deleting an Item

const deleteTodo = useMutation({
  mutationFn: (todoId) => {
    return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
  },
  onMutate: async (todoId) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);

    queryClient.setQueryData(['todos'], (old) =>
      old.filter((todo) => todo.id !== todoId)
    );

    return { previousTodos };
  },
  onError: (err, todoId, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Multiple Query Updates

Update multiple related queries optimistically:

const updateUser = useMutation({
  mutationFn: ({ userId, updates }) => {
    return fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify(updates),
    }).then(res => res.json());
  },
  onMutate: async ({ userId, updates }) => {
    // Cancel all related queries
    await queryClient.cancelQueries({ queryKey: ['users'] });
    await queryClient.cancelQueries({ queryKey: ['user', userId] });

    // Snapshot previous data
    const previousUsers = queryClient.getQueryData(['users']);
    const previousUser = queryClient.getQueryData(['user', userId]);

    // Update users list
    queryClient.setQueryData(['users'], (old) =>
      old?.map((user) =>
        user.id === userId ? { ...user, ...updates } : user
      )
    );

    // Update individual user
    queryClient.setQueryData(['user', userId], (old) => ({
      ...old,
      ...updates,
    }));

    return { previousUsers, previousUser };
  },
  onError: (err, { userId }, context) => {
    // Rollback both queries
    queryClient.setQueryData(['users'], context.previousUsers);
    queryClient.setQueryData(['user', userId], context.previousUser);
  },
  onSettled: (data, error, { userId }) => {
    queryClient.invalidateQueries({ queryKey: ['users'] });
    queryClient.invalidateQueries({ queryKey: ['user', userId] });
  },
});

Optimistic Updates with Infinite Queries

const addPost = useMutation({
  mutationFn: (newPost) => {
    return fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(newPost),
    }).then(res => res.json());
  },
  onMutate: async (newPost) => {
    await queryClient.cancelQueries({ queryKey: ['posts'] });
    const previousPosts = queryClient.getQueryData(['posts']);

    // Add to first page
    queryClient.setQueryData(['posts'], (old) => {
      if (!old?.pages.length) return old;

      return {
        ...old,
        pages: [
          {
            ...old.pages[0],
            posts: [
              { ...newPost, id: 'temp-' + Date.now() },
              ...old.pages[0].posts,
            ],
          },
          ...old.pages.slice(1),
        ],
      };
    });

    return { previousPosts };
  },
  onError: (err, newPost, context) => {
    queryClient.setQueryData(['posts'], context.previousPosts);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

UI Feedback During Optimistic Updates

Show Pending State

function TodoItem({ todo }) {
  const queryClient = useQueryClient();

  const toggleTodo = useMutation({
    mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' }),
    onMutate: async (todoId) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData(['todos']);

      queryClient.setQueryData(['todos'], (old) =>
        old.map((t) =>
          t.id === todoId
            ? { ...t, done: !t.done, isPending: true } // Mark as pending
            : t
        )
      );

      return { previousTodos };
    },
    onSuccess: (data, todoId) => {
      // Remove pending state
      queryClient.setQueryData(['todos'], (old) =>
        old.map((t) =>
          t.id === todoId ? { ...t, isPending: false } : t
        )
      );
    },
    onError: (err, todoId, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
  });

  return (
    <div className={todo.isPending ? 'opacity-50' : ''}>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => toggleTodo.mutate(todo.id)}
        disabled={todo.isPending}
      />
      {todo.title}
    </div>
  );
}

Show Error State

const [error, setError] = useState(null);

const updateTodo = useMutation({
  mutationFn: updateTodoApi,
  onMutate: async (updates) => {
    setError(null); // Clear previous errors
    // ... optimistic update
  },
  onError: (err, variables, context) => {
    setError(err.message);
    // ... rollback
  },
});

return (
  <div>
    {error && <div className="error">{error}</div>}
    {/* render todo */}
  </div>
);

Advanced Patterns

Optimistic Update with Retry

const updateTodo = useMutation({
  mutationFn: updateTodoApi,
  retry: 3,
  onMutate: async (updates) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);

    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === updates.id
          ? { ...todo, ...updates, _optimistic: true }
          : todo
      )
    );

    return { previousTodos };
  },
  onSuccess: (data, variables) => {
    // Remove optimistic flag
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === variables.id
          ? { ...todo, _optimistic: false }
          : todo
      )
    );
  },
  onError: (err, variables, context) => {
    // Only rollback if all retries failed
    if (err.retryCount >= 3) {
      queryClient.setQueryData(['todos'], context.previousTodos);
    }
  },
});

Debounced Optimistic Updates

For rapid updates like typing in a search or editing text:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useDebouncedCallback } from 'use-debounce';

function TodoTitle({ todo }) {
  const queryClient = useQueryClient();
  const [localTitle, setLocalTitle] = useState(todo.title);

  const updateTodo = useMutation({
    mutationFn: ({ id, title }) => {
      return fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        body: JSON.stringify({ title }),
      }).then(res => res.json());
    },
    onMutate: async ({ id, title }) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData(['todos']);

      queryClient.setQueryData(['todos'], (old) =>
        old.map((t) => (t.id === id ? { ...t, title } : t))
      );

      return { previousTodos };
    },
    onError: (err, variables, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
      setLocalTitle(context.previousTodos.find(t => t.id === variables.id).title);
    },
  });

  const debouncedUpdate = useDebouncedCallback(
    (id, title) => updateTodo.mutate({ id, title }),
    500
  );

  const handleChange = (e) => {
    const newTitle = e.target.value;
    setLocalTitle(newTitle);
    debouncedUpdate(todo.id, newTitle);
  };

  return <input value={localTitle} onChange={handleChange} />;
}

Optimistic Delete with Undo

function TodoItem({ todo }) {
  const queryClient = useQueryClient();
  const [showUndo, setShowUndo] = useState(false);

  const deleteTodo = useMutation({
    mutationFn: (todoId) => {
      return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
    },
    onMutate: async (todoId) => {
      setShowUndo(true);
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData(['todos']);

      queryClient.setQueryData(['todos'], (old) =>
        old.filter((t) => t.id !== todoId)
      );

      // Auto-hide undo after 5 seconds
      setTimeout(() => setShowUndo(false), 5000);

      return { previousTodos };
    },
    onError: (err, todoId, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
      setShowUndo(false);
    },
  });

  const handleUndo = () => {
    deleteTodo.reset(); // Reset mutation state
    queryClient.invalidateQueries({ queryKey: ['todos'] });
    setShowUndo(false);
  };

  if (showUndo) {
    return (
      <div className="undo-banner">
        Todo deleted <button onClick={handleUndo}>Undo</button>
      </div>
    );
  }

  return (
    <div>
      {todo.title}
      <button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button>
    </div>
  );
}

Batch Optimistic Updates

const markAllDone = useMutation({
  mutationFn: () => {
    return fetch('/api/todos/mark-all-done', { method: 'POST' });
  },
  onMutate: async () => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);

    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) => ({ ...todo, done: true }))
    );

    return { previousTodos };
  },
  onError: (err, variables, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Cancel In-Flight Mutations

Cancel mutations that are no longer needed:

function QuickEdit({ todo }) {
  const queryClient = useQueryClient();

  const updateTodo = useMutation({
    mutationFn: ({ id, title }) => {
      return fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        body: JSON.stringify({ title }),
      }).then(res => res.json());
    },
    onMutate: async ({ id, title }) => {
      // Cancel previous mutations for this todo
      queryClient.cancelMutations({ mutationKey: ['updateTodo', id] });

      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData(['todos']);

      queryClient.setQueryData(['todos'], (old) =>
        old.map((t) => (t.id === id ? { ...t, title } : t))
      );

      return { previousTodos };
    },
  });

  return (
    <input
      onChange={(e) => updateTodo.mutate({ id: todo.id, title: e.target.value })}
    />
  );
}

Best Practices

  1. Always Cancel Queries

    await queryClient.cancelQueries({ queryKey: ['todos'] });
    

    Prevents race conditions between optimistic update and ongoing fetches.

  2. Always Return Context

    onMutate: async (variables) => {
      const previousData = queryClient.getQueryData(['todos']);
      // ... update
      return { previousData }; // Critical for rollback
    }
    
  3. Always Handle Errors

    onError: (err, variables, context) => {
      queryClient.setQueryData(['todos'], context.previousData);
    }
    
  4. Use onSettled for Refetch

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
    

    Ensures data stays in sync with server.

  5. Show Visual Feedback

    • Add loading/pending states to optimistically updated items
    • Show error messages on failure
    • Provide undo functionality where appropriate
  6. Handle Multiple Related Queries

    • Update all queries that display the same data
    • Rollback all queries on error
  7. Consider Using Temporary IDs

    • For created items, use temp IDs until server responds
    • Replace with server IDs on success
  8. Test Error Cases

    • Verify rollback works correctly
    • Test network failures
    • Test validation errors from server