Skip to content
intermediatePhase 37 · Frontend Architecture

Optimistic Updates

Update UI immediately before server confirmation for better UX.

30m
0 problems
Topic Progress0%

Implementation Pattern

Implementation Pattern

Optimistic updates immediately reflect changes in the UI before server confirmation.

React Query Pattern

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

  const addTodo = useMutation({
    mutationFn: (newTodo) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      }).then(r => r.json()),

    // Optimistic update
    onMutate: async (newTodo) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] });

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

      // Optimistically update to the new value
      queryClient.setQueryData(['todos'], (old) => [
        ...old,
        { ...newTodo, id: 'temp-' + Date.now(), isPending: true },
      ]);

      // Return context object with the snapshotted value
      return { previousTodos };
    },

    // If the mutation fails, use the context we returned to roll back
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
      toast.error('Failed to add todo');
    },

    // Always refetch after error or success to ensure consistency
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <div>
      <button
        onClick={() => addTodo.mutate({ text: 'New Todo', completed: false })}
        disabled={addTodo.isPending}
      >
        Add Todo
      </button>
      {addTodo.isPending && <span>Saving...</span>}
    </div>
  );
}

Zustand Pattern

const useStore = create((set, get) => ({
  items: [],
  pendingItems: [],

  addItem: async (item) => {
    const tempId = 'temp-' + Date.now();
    const optimisticItem = { ...item, id: tempId };

    // Optimistic update
    set((state) => ({
      items: [...state.items, optimisticItem],
      pendingItems: [...state.pendingItems, tempId],
    }));

    try {
      const response = await fetch('/api/items', {
        method: 'POST',
        body: JSON.stringify(item),
      });
      const savedItem = await response.json();

      // Replace temp with real item
      set((state) => ({
        items: state.items.map(i =>
          i.id === tempId ? savedItem : i
        ),
        pendingItems: state.pendingItems.filter(id => id !== tempId),
      }));
    } catch (error) {
      // Rollback
      set((state) => ({
        items: state.items.filter(i => i.id !== tempId),
        pendingItems: state.pendingItems.filter(id => id !== tempId),
      }));
      toast.error('Failed to add item');
    }
  },
}));

Key Principles

  1. Cancel background queries before updating
  2. Save previous state for rollback
  3. Update cache optimistically
  4. Rollback on error
  5. Invalidate/refetch on settlement

Rollback Handling

Rollback Handling

Properly revert changes when server operations fail.

Rollback Patterns

// Simple rollback
const deleteTodo = useMutation({
  mutationFn: (id) =>
    fetch(`/api/todos/${id}`, { method: 'DELETE' }),

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

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

    return { previousTodos };
  },

  onError: (err, id, context) => {
    // Rollback to previous state
    queryClient.setQueryData(['todos'], context.previousTodos);
    toast.error('Failed to delete todo');
  },

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

Partial Rollback

// When you need to keep some changes
const updateTodo = useMutation({
  mutationFn: (todo) =>
    fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: JSON.stringify(todo),
    }).then(r => r.json()),

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

    // Update with optimistic data
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === newTodo.id ? { ...todo, ...newTodo } : todo
      )
    );

    return { previousTodos };
  },

  onError: (err, newTodo, context) => {
    // Partial rollback: keep client fields, revert server fields
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === newTodo.id
          ? { ...todo, ...context.previousTodos.find(t => t.id === newTodo.id) }
          : todo
      )
    );
  },

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

Rollback with Temp IDs

const addTodo = useMutation({
  mutationFn: (newTodo) =>
    fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ text: newTodo.text }),
    }).then(r => r.json()),

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

    const tempTodo = {
      ...newTodo,
      id: 'temp-' + Date.now(),
      createdAt: new Date().toISOString(),
      _isPending: true,
    };

    queryClient.setQueryData(['todos'], (old) => [...old, tempTodo]);

    return { previousTodos, tempId: tempTodo.id };
  },

  onSuccess: (data, variables, context) => {
    // Replace temp with real data
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === context.tempId ? { ...data, _isPending: false } : todo
      )
    );
  },

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

User Feedback During Rollback

function TodoItem({ todo }) {
  const deleteTodo = useMutation({/* ... */});

  return (
    <div className={`todo-item ${todo._isPending ? 'pending' : ''}`}>
      <span>{todo.text}</span>
      <button
        onClick={() => deleteTodo.mutate(todo.id)}
        disabled={deleteTodo.isPending}
      >
        {deleteTodo.isPending ? 'Deleting...' : 'Delete'}
      </button>
    </div>
  );
}

Server Reconciliation

Server Reconciliation

Ensure UI consistency after optimistic updates.

Refetch Strategies

// After mutation, always refetch for consistency
const updateTodo = useMutation({
  mutationFn: updateTodoApi,
  onMutate: async (newTodo) => {
    // Optimistic update
  },
  onError: (err, newTodo, context) => {
    // Rollback
  },
  onSettled: () => {
    // Always refetch
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

// Or refetch specific queries
const addProduct = useMutation({
  mutationFn: createProduct,
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['products'] });
    queryClient.invalidateQueries({ queryKey: ['productStats'] });
  },
});

Handling Server Response Differences

const addTodo = useMutation({
  mutationFn: (text) =>
    fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ text }),
    }).then(r => r.json()),

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

    const tempTodo = {
      id: 'temp-' + Date.now(),
      text,
      completed: false,
      createdAt: new Date().toISOString(),
    };

    queryClient.setQueryData(['todos'], (old) => [...old, tempTodo]);

    return { previousTodos, tempId: tempTodo.id };
  },

  onSuccess: (serverTodo, variables, context) => {
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) => {
        if (todo.id === context.tempId) {
          // Use server data as source of truth
          return {
            ...serverTodo,
            // Keep any client-only fields if needed
            _justAdded: true,
          };
        }
        return todo;
      })
    );
  },

  onError: (err, variables, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
    toast.error('Failed to add todo');
  },
});

Real-time Collaboration

// Handle concurrent updates
const updateTodo = useMutation({
  mutationFn: (todo) =>
    fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: JSON.stringify({
        ...todo,
        version: todo.version, // Optimistic concurrency control
      }),
    }).then(r => {
      if (r.status === 409) {
        throw new Error('Conflict detected');
      }
      return r.json();
    }),

  onError: (err) => {
    if (err.message === 'Conflict detected') {
      // Refetch to get latest state
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      toast.warning('Item was updated by another user. Refreshing...');
    }
  },
});

Best Practices

  1. Always refetch after mutation for consistency
  2. Use temp IDs for new items
  3. Show pending state for in-flight items
  4. Handle conflicts with version control
  5. Provide user feedback during rollback
  6. Never lose user data - always rollback gracefully

Practice Problems

0/3solved
Build Optimistic Updates Component

Create a reusable React component implementing Optimistic Updates. Include proper state management and accessibility.

Solution
// Production-ready component with:
// - Proper TypeScript types
// - Accessibility (ARIA)
// - Error boundaries
// - Loading states
// - Memoization where needed
Optimistic Updates Testing

Write unit and integration tests for Optimistic Updates using React Testing Library.

Solution
// Test coverage:
// 1. Rendering tests
// 2. Interaction tests
// 3. Edge case tests
// 4. Accessibility tests
Optimistic Updates Performance

Optimize Optimistic Updates for performance. Consider memoization, code splitting, and bundle size.

Solution
// Optimization techniques:
// 1. React.memo / useMemo / useCallback
// 2. Code splitting with lazy()
// 3. Virtual scrolling for lists
// 4. Image lazy loading
// 5. Bundle analysis

Quiz

1. Why cancel queries before optimistic updates?

Question 1 options

2. What should you return from onMutate for rollback?

Question 2 options

3. When should you use optimistic updates?

Question 3 options

4. What is server reconciliation?

Question 4 options

5. How should you handle conflicts in optimistic updates?

Question 5 options

Flashcards

Question

What is the purpose of onMutate in React Query?

Answer

To perform optimistic updates, cancel queries, and save previous state for rollback.

Question

Why always refetch after optimistic updates?

Answer

To ensure the UI eventually reflects the actual server state and resolve any discrepancies.

Question

What is a temp ID?

Answer

A temporary identifier assigned to optimistic items before receiving the real ID from the server.

Question

What is optimistic concurrency control?

Answer

Using version numbers to detect and handle conflicts when multiple users modify the same data.

Question

What is Optimistic Updates?

Answer

Optimistic Updates is a key concept in frontend development.

Revision Notes

Key Takeaways

  • 1.Optimistic updates provide instant feedback for predictable operations
  • 2.Always cancel queries and save previous state for rollback
  • 3.Refetch after mutations to ensure server consistency
  • 4.Use temp IDs for new items before server confirmation
  • 5.Handle conflicts with version control and user feedback

Interview Tips

  • Explain the optimistic update pattern with React Query
  • Describe how to handle rollback on errors
  • Discuss when optimistic updates are appropriate vs when to avoid them

Cheat Sheet

Optimistic Updates Cheat Sheet

React Query Pattern

const mutation = useMutation({
  mutationFn: apiCall,
  onMutate: async (data) => {
    await queryClient.cancelQueries({ queryKey });
    const previous = queryClient.getQueryData(queryKey);
    queryClient.setQueryData(queryKey, optimisticUpdate);
    return { previous };
  },
  onError: (err, data, context) => {
    queryClient.setQueryData(queryKey, context.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey });
  }
});

Key Steps

  1. Cancel outgoing queries
  2. Snapshot previous state
  3. Update cache optimistically
  4. Return context with snapshot
  5. Rollback on error
  6. Refetch on settlement