Front-end Engineering Lab

Optimistic Updates

Update UI immediately before server confirms, for instant feedback

Don't make users wait for the server. Update the UI immediately and handle failures gracefully. This guide shows how.

🎯 The Goal

Without optimistic updates:
User clicks "Like" → Wait for server → Update UI
⏱️ 500ms delay = Bad UX

With optimistic updates:
User clicks "Like" → Update UI instantly → Sync with server
⚡ 0ms perceived delay = Great UX

❤️ Pattern 1: Simple Like Button

Basic optimistic update example.

function LikeButton({ postId, initialLiked, initialCount }: Props) {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);
  
  async function handleClick() {
    // Save previous state for rollback
    const prevLiked = liked;
    const prevCount = count;
    
    try {
      // 1. Update UI immediately (optimistic)
      setLiked(!liked);
      setCount(liked ? count - 1 : count + 1);
      
      // 2. Send request to server
      await fetch(`/api/posts/${postId}/like`, {
        method: liked ? 'DELETE' : 'POST'
      });
      
      // 3. Success! UI already updated
    } catch (error) {
      // 4. Rollback on error
      setLiked(prevLiked);
      setCount(prevCount);
      
      toast.error('Failed to like post');
    }
  }
  
  return (
    <button onClick={handleClick}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  );
}

🔄 Pattern 2: React Query Optimistic Updates

Use React Query's built-in optimistic updates.

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

interface Post {
  id: string;
  liked: boolean;
  likeCount: number;
}

function LikeButton({ postId }: { postId: string }) {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: async (liked: boolean) => {
      const method = liked ? 'POST' : 'DELETE';
      return fetch(`/api/posts/${postId}/like`, { method });
    },
    
    // Optimistic update
    onMutate: async (newLiked) => {
      // Cancel outgoing queries
      await queryClient.cancelQueries({ queryKey: ['post', postId] });
      
      // Snapshot previous value
      const previousPost = queryClient.getQueryData<Post>(['post', postId]);
      
      // Optimistically update
      queryClient.setQueryData<Post>(['post', postId], (old) => ({
        ...old!,
        liked: newLiked,
        likeCount: old!.likeCount + (newLiked ? 1 : -1)
      }));
      
      // Return context for rollback
      return { previousPost };
    },
    
    // Rollback on error
    onError: (err, newLiked, context) => {
      queryClient.setQueryData(['post', postId], context?.previousPost);
      toast.error('Failed to update like');
    },
    
    // Refetch on success/error
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] });
    },
  });
  
  const { data: post } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId)
  });
  
  return (
    <button onClick={() => mutation.mutate(!post?.liked)}>
      {post?.liked ? '❤️' : '🤍'} {post?.likeCount}
    </button>
  );
}

📝 Pattern 3: Todo List with Optimistic Add/Delete

Complete CRUD with optimistic updates.

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

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

function TodoList() {
  const queryClient = useQueryClient();
  
  const { data: todos = [] } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  });
  
  // Add todo
  const addMutation = useMutation({
    mutationFn: (text: string) => 
      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<Todo[]>(['todos']);
      
      // Optimistic add with temporary ID
      const optimisticTodo: Todo = {
        id: `temp-${Date.now()}`,
        text,
        completed: false
      };
      
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
        ...old,
        optimisticTodo
      ]);
      
      return { previousTodos };
    },
    
    onError: (err, text, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
      toast.error('Failed to add todo');
    },
    
    onSuccess: (newTodo, text, context) => {
      // Replace temp ID with real ID from server
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
        old.map(todo => 
          todo.id.startsWith('temp-') ? newTodo : todo
        )
      );
    }
  });
  
  // Delete todo
  const deleteMutation = useMutation({
    mutationFn: (id: string) => 
      fetch(`/api/todos/${id}`, { method: 'DELETE' }),
    
    onMutate: async (id) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
      
      // Optimistic delete
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
        old.filter(todo => todo.id !== id)
      );
      
      return { previousTodos };
    },
    
    onError: (err, id, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
      toast.error('Failed to delete todo');
    }
  });
  
  // Toggle completion
  const toggleMutation = useMutation({
    mutationFn: ({ id, completed }: { id: string; completed: boolean }) =>
      fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        body: JSON.stringify({ completed })
      }),
    
    onMutate: async ({ id, completed }) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
      
      // Optimistic toggle
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
        old.map(todo =>
          todo.id === id ? { ...todo, completed } : todo
        )
      );
      
      return { previousTodos };
    },
    
    onError: (err, variables, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
      toast.error('Failed to update todo');
    }
  });
  
  return (
    <div>
      <form onSubmit={(e) => {
        e.preventDefault();
        const text = new FormData(e.currentTarget).get('text') as string;
        addMutation.mutate(text);
        e.currentTarget.reset();
      }}>
        <input name="text" placeholder="Add todo..." />
        <button type="submit">Add</button>
      </form>
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={(e) => 
                toggleMutation.mutate({ 
                  id: todo.id, 
                  completed: e.target.checked 
                })
              }
            />
            {todo.text}
            <button onClick={() => deleteMutation.mutate(todo.id)}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

🛒 Pattern 4: Shopping Cart

Optimistic cart operations.

interface CartItem {
  id: string;
  productId: string;
  quantity: number;
  price: number;
}

function ShoppingCart() {
  const queryClient = useQueryClient();
  
  const { data: cart = [] } = useQuery({
    queryKey: ['cart'],
    queryFn: fetchCart
  });
  
  const updateQuantity = useMutation({
    mutationFn: ({ itemId, quantity }: { itemId: string; quantity: number }) =>
      fetch(`/api/cart/${itemId}`, {
        method: 'PATCH',
        body: JSON.stringify({ quantity })
      }),
    
    onMutate: async ({ itemId, quantity }) => {
      await queryClient.cancelQueries({ queryKey: ['cart'] });
      
      const previousCart = queryClient.getQueryData<CartItem[]>(['cart']);
      
      // Optimistic update
      queryClient.setQueryData<CartItem[]>(['cart'], (old = []) =>
        old.map(item =>
          item.id === itemId ? { ...item, quantity } : item
        )
      );
      
      return { previousCart };
    },
    
    onError: (err, variables, context) => {
      queryClient.setQueryData(['cart'], context?.previousCart);
      toast.error('Failed to update quantity');
    }
  });
  
  const removeItem = useMutation({
    mutationFn: (itemId: string) =>
      fetch(`/api/cart/${itemId}`, { method: 'DELETE' }),
    
    onMutate: async (itemId) => {
      await queryClient.cancelQueries({ queryKey: ['cart'] });
      
      const previousCart = queryClient.getQueryData<CartItem[]>(['cart']);
      
      // Optimistic remove with animation
      queryClient.setQueryData<CartItem[]>(['cart'], (old = []) =>
        old.filter(item => item.id !== itemId)
      );
      
      return { previousCart, removedItem: previousCart?.find(i => i.id === itemId) };
    },
    
    onError: (err, itemId, context) => {
      queryClient.setQueryData(['cart'], context?.previousCart);
      toast.error('Failed to remove item');
    },
    
    onSuccess: (data, itemId, context) => {
      // Show undo option
      toast.success('Item removed', {
        action: {
          label: 'Undo',
          onClick: () => {
            // Add back to cart
            if (context.removedItem) {
              queryClient.setQueryData<CartItem[]>(['cart'], (old = []) => [
                ...old,
                context.removedItem!
              ]);
            }
          }
        }
      });
    }
  });
  
  const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
  
  return (
    <div>
      <h2>Cart ({cart.length} items)</h2>
      
      {cart.map(item => (
        <div key={item.id}>
          <span>{item.productId}</span>
          
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => updateQuantity.mutate({
              itemId: item.id,
              quantity: parseInt(e.target.value)
            })}
            min="1"
          />
          
          <button onClick={() => removeItem.mutate(item.id)}>
            Remove
          </button>
        </div>
      ))}
      
      <div>Total: ${total.toFixed(2)}</div>
    </div>
  );
}

💬 Pattern 5: Real-Time Comments

Optimistic comment posting.

interface Comment {
  id: string;
  text: string;
  author: string;
  createdAt: Date;
  status?: 'pending' | 'sent' | 'failed';
}

function CommentSection({ postId }: { postId: string }) {
  const queryClient = useQueryClient();
  const { user } = useAuth();
  
  const { data: comments = [] } = useQuery({
    queryKey: ['comments', postId],
    queryFn: () => fetchComments(postId)
  });
  
  const postComment = useMutation({
    mutationFn: (text: string) =>
      fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        body: JSON.stringify({ text })
      }).then(r => r.json()),
    
    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ['comments', postId] });
      
      const previousComments = queryClient.getQueryData<Comment[]>(['comments', postId]);
      
      // Optimistic comment with temporary ID and "pending" status
      const optimisticComment: Comment = {
        id: `temp-${Date.now()}`,
        text,
        author: user.name,
        createdAt: new Date(),
        status: 'pending'
      };
      
      queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) => [
        ...old,
        optimisticComment
      ]);
      
      return { previousComments };
    },
    
    onError: (err, text, context) => {
      // Mark as failed instead of removing
      queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) =>
        old.map(comment =>
          comment.id.startsWith('temp-') 
            ? { ...comment, status: 'failed' as const }
            : comment
        )
      );
      
      toast.error('Failed to post comment');
    },
    
    onSuccess: (newComment, text, context) => {
      // Replace temp with real comment
      queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) =>
        old.map(comment =>
          comment.id.startsWith('temp-') 
            ? { ...newComment, status: 'sent' as const }
            : comment
        )
      );
    }
  });
  
  return (
    <div>
      <h3>Comments</h3>
      
      {comments.map(comment => (
        <div key={comment.id} className={comment.status}>
          <strong>{comment.author}</strong>
          <p>{comment.text}</p>
          
          {comment.status === 'pending' && <span>Sending...</span>}
          {comment.status === 'failed' && (
            <button onClick={() => postComment.mutate(comment.text)}>
              Retry
            </button>
          )}
        </div>
      ))}
      
      <form onSubmit={(e) => {
        e.preventDefault();
        const text = new FormData(e.currentTarget).get('text') as string;
        postComment.mutate(text);
        e.currentTarget.reset();
      }}>
        <textarea name="text" placeholder="Write a comment..." />
        <button type="submit">Post</button>
      </form>
    </div>
  );
}

📊 Best Practices

1. Always Provide Rollback

// ✅ GOOD: Save previous state
const previous = queryClient.getQueryData(['data']);
// ... optimistic update ...
// On error: restore previous

// ❌ BAD: No rollback
// ... optimistic update ...
// On error: ??? data is wrong now

2. Show Status Indicators

// ✅ GOOD: Show pending state
<div className={item.status === 'pending' ? 'opacity-50' : ''}>
  {item.text}
  {item.status === 'pending' && <Spinner />}
</div>

// ❌ BAD: No indication
<div>{item.text}</div>

3. Handle Failures Gracefully

// ✅ GOOD: Allow retry
{comment.status === 'failed' && (
  <button onClick={retry}>Retry</button>
)}

// ❌ BAD: Silent failure
// User has no idea it failed

4. Use Temporary IDs

// ✅ GOOD: Unique temp ID
const tempId = `temp-${Date.now()}-${Math.random()}`;

// ❌ BAD: Duplicate IDs
const tempId = 'temp';

🏢 Real-World Examples

Twitter

// Optimistic tweets
// Instant likes/retweets
// Retry on failure
// Show sending indicator

Discord

// Optimistic messages
// Failed state with retry
// Offline queue
// Message ordering

Notion

// Optimistic everything
// Offline-first
// Conflict resolution
// Auto-save with sync status

📚 Key Takeaways

  1. Update UI immediately - Don't wait for server
  2. Always have rollback - Handle failures
  3. Show status - Pending, sent, failed
  4. Use temp IDs - Replace with real on success
  5. Cancel queries - Prevent race conditions
  6. Provide retry - Let users fix failures
  7. Test offline - Optimistic updates shine here

Optimistic updates are the difference between good and great UX. Always use them for user actions.

On this page