Expert60 min read

React 19: Optimistic UI Updates with useOptimistic

Master the useOptimistic hook to create instant, responsive user interfaces with automatic rollback on errors.

Topics Covered:

useOptimisticOptimistic UIError HandlingServer Actions

Prerequisites:

  • Advanced State Management Patterns

Overview

useOptimistic is a powerful React 19 hook that enables optimistic UI updates - showing immediate feedback to users before server confirmation. This creates a more responsive, modern user experience. When combined with error handling, it automatically reverts changes if the action fails, ensuring data consistency. This tutorial covers when and how to use useOptimistic effectively.

Lesson 1: Understanding Optimistic UI Patterns

Optimistic UI updates show changes immediately, assuming the action will succeed. If it fails, the UI reverts to the previous state. Benefits: • Instant feedback - no waiting for server • Better perceived performance • Smoother user experience • Reduced perceived latency When to Use: • Actions likely to succeed (likes, follows, saves) • High success rate operations • When rollback is acceptable • Improving perceived performance When NOT to Use: • Critical financial transactions • Actions that frequently fail • When data integrity is paramount • Operations requiring server validation

Code Example:
// Traditional approach (pessimistic)
async function handleLike() {
  setLoading(true);
  try {
    await likePost(postId);
    setLikes(prev => prev + 1);
  } catch (error) {
    showError('Failed to like');
  } finally {
    setLoading(false);
  }
}

// User sees: Loading... → Success
// Feels slow, waits for server

// Optimistic approach
async function handleLike() {
  addOptimisticLike(1); // Update immediately
  try {
    await likePost(postId);
    setLikes(prev => prev + 1); // Confirm
  } catch (error) {
    // Automatic rollback
    showError('Failed to like');
  }
}

// User sees: Immediate update
// Feels instant, confirms in background

Optimistic UI provides immediate feedback. The user sees changes instantly, and the UI confirms or reverts based on the server response.

Lesson 2: useOptimistic Hook Basics

useOptimistic manages optimistic state updates with automatic rollback. Hook Signature: ```typescript const [optimisticState, addOptimistic] = useOptimistic( state, updateFn ); ``` Parameters: • state: The actual state value • updateFn: Function that computes optimistic state Returns: • optimisticState: The optimistic state (or actual if no pending updates) • addOptimistic: Function to trigger optimistic update

Code Example:
import { useOptimistic, useState } from 'react';

function LikeButton() {
  const [likes, setLikes] = useState(10);
  
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    // Update function: (currentState, optimisticValue) => newState
    (currentLikes, amount: number) => currentLikes + amount
  );
  
  async function handleLike() {
    // Step 1: Optimistic update (immediate)
    addOptimisticLike(1);
    
    try {
      // Step 2: Server call
      const response = await fetch('/api/like', {
        method: 'POST',
        body: JSON.stringify({ postId: 1 }),
      });
      
      if (!response.ok) throw new Error('Failed');
      
      // Step 3: Update actual state (confirms optimistic)
      const data = await response.json();
      setLikes(data.likes);
    } catch (error) {
      // Step 4: On error, useOptimistic automatically reverts
      // because we didn't update the actual state
      console.error('Like failed:', error);
    }
  }
  
  return (
    <button onClick={handleLike}>
      ❤️ {optimisticLikes}
    </button>
  );
}

// How it works:
// 1. User clicks → optimisticLikes becomes 11 (optimistic)
// 2. Server succeeds → setLikes(11) → optimisticLikes stays 11 (confirmed)
// 3. Server fails → setLikes not called → optimisticLikes reverts to 10

useOptimistic manages temporary optimistic state. When you call addOptimisticLike, it immediately shows the optimistic value. If the actual state updates, it confirms. If not (error case), it automatically reverts.

Lesson 3: Complex Optimistic Updates

useOptimistic can handle complex state structures, not just numbers. Common Patterns: • Adding items to lists • Updating nested objects • Toggling boolean states • Incrementing/decrementing counters Best Practices: • Keep update function pure • Handle complex state transformations • Use proper TypeScript types • Consider edge cases

Code Example:
// Complex state: Array of items
function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: 'Learn React', completed: false },
  ]);
  
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
  );
  
  async function handleAddTodo(text: string) {
    const tempTodo: Todo = {
      id: Date.now(), // Temporary ID
      text,
      completed: false,
    };
    
    // Add optimistically
    addOptimisticTodo(tempTodo);
    
    try {
      const savedTodo = await createTodo(text);
      // Replace temp with real todo
      setTodos(prev => [
        ...prev.filter(t => t.id !== tempTodo.id),
        savedTodo,
      ]);
    } catch (error) {
      // Auto-reverts, removing tempTodo
      console.error('Failed to add todo');
    }
  }
  
  // Complex: Updating nested state
  function CommentList() {
    const [comments, setComments] = useState<Comment[]>([]);
    
    const [optimisticComments, updateOptimistic] = useOptimistic(
      comments,
      (current, update: { id: number; likes: number }) =>
        current.map(comment =>
          comment.id === update.id
            ? { ...comment, likes: update.likes }
            : comment
        )
    );
    
    async function handleLikeComment(commentId: number) {
      const comment = comments.find(c => c.id === commentId);
      if (!comment) return;
      
      // Optimistic: increment likes
      updateOptimistic({ id: commentId, likes: comment.likes + 1 });
      
      try {
        const updated = await likeComment(commentId);
        setComments(prev =>
          prev.map(c => (c.id === commentId ? updated : c))
        );
      } catch (error) {
        // Auto-reverts
      }
    }
  }
  
  // Toggle pattern
  function FollowButton({ userId }: { userId: number }) {
    const [isFollowing, setIsFollowing] = useState(false);
    
    const [optimisticFollowing, toggleOptimistic] = useOptimistic(
      isFollowing,
      (current, _) => !current // Toggle
    );
    
    async function handleToggle() {
      toggleOptimistic(null); // Value doesn't matter for toggle
      
      try {
        if (isFollowing) {
          await unfollow(userId);
        } else {
          await follow(userId);
        }
        setIsFollowing(prev => !prev);
      } catch (error) {
        // Auto-reverts
      }
    }
    
    return (
      <button onClick={handleToggle}>
        {optimisticFollowing ? 'Unfollow' : 'Follow'}
      </button>
    );
  }

useOptimistic works with any state structure. The update function receives the current state and the optimistic value, returning the new optimistic state. Keep the function pure and predictable.

Lesson 4: Error Handling and Rollback

Proper error handling ensures data consistency when optimistic updates fail. Error Handling Strategies: • Automatic rollback (built-in) • Manual error notifications • Retry mechanisms • Error state management Common Patterns: • Show error messages • Retry failed operations • Log errors for debugging • Graceful degradation

Code Example:
// Error handling with notifications
function LikeButton() {
  const [likes, setLikes] = useState(0);
  const [error, setError] = useState<string | null>(null);
  
  const [optimisticLikes, addOptimistic] = useOptimistic(
    likes,
    (current, amount: number) => current + amount
  );
  
  async function handleLike() {
    // Clear previous errors
    setError(null);
    
    // Optimistic update
    addOptimistic(1);
    
    try {
      const response = await fetch('/api/like', {
        method: 'POST',
      });
      
      if (!response.ok) {
        throw new Error('Like failed');
      }
      
      const data = await response.json();
      setLikes(data.likes);
    } catch (error) {
      // Error occurred - optimistic state auto-reverts
      setError('Failed to like. Please try again.');
      
      // Optional: Retry mechanism
      setTimeout(() => {
        handleLike(); // Retry
      }, 2000);
    }
  }
  
  return (
    <div>
      <button onClick={handleLike}>
        ❤️ {optimisticLikes}
      </button>
      {error && <p className="error">{error}</p>}
    </div>
  );
}

// With retry logic
function useOptimisticWithRetry<T, A>(
  state: T,
  updateFn: (current: T, optimisticValue: A) => T
) {
  const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
  const [retryCount, setRetryCount] = useState(0);
  
  return {
    optimisticState,
    addOptimistic: (value: A) => {
      addOptimistic(value);
      setRetryCount(0); // Reset retry count
    },
    retry: () => setRetryCount(prev => prev + 1),
    retryCount,
  };
}

// Advanced: Debounce rapid optimistic updates
function useDebouncedOptimistic<T, A>(
  state: T,
  updateFn: (current: T, optimisticValue: A) => T,
  delay: number = 300
) {
  const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
  const timeoutRef = useRef<NodeJS.Timeout>();
  
  const debouncedAdd = useCallback((value: A) => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    
    timeoutRef.current = setTimeout(() => {
      addOptimistic(value);
    }, delay);
  }, [addOptimistic, delay]);
  
  return { optimisticState, addOptimistic: debouncedAdd };
}

Error handling with useOptimistic is straightforward - if the actual state doesn't update, the optimistic state automatically reverts. Add error messages and retry logic for better UX.

Lesson 5: Real-World Use Cases

Explore practical applications of useOptimistic in real applications. Common Use Cases: • Social media interactions (likes, comments) • Shopping cart updates • Form submissions • List item operations (add, delete, update) • Real-time collaboration features

Code Example:
// Real-world examples

// 1. Social media like counter
function Post({ postId }: { postId: number }) {
  const [likes, setLikes] = useState(0);
  const [isLiked, setIsLiked] = useState(false);
  
  const [optimisticLikes, addOptimistic] = useOptimistic(
    likes,
    (current, delta: number) => current + delta
  );
  
  const [optimisticIsLiked, toggleOptimistic] = useOptimistic(
    isLiked,
    (current, _) => !current
  );
  
  async function handleLike() {
    const wasLiked = isLiked;
    toggleOptimistic(null);
    addOptimistic(wasLiked ? -1 : 1);
    
    try {
      if (wasLiked) {
        await unlikePost(postId);
        setIsLiked(false);
        setLikes(prev => prev - 1);
      } else {
        await likePost(postId);
        setIsLiked(true);
        setLikes(prev => prev + 1);
      }
    } catch (error) {
      // Both revert automatically
    }
  }
  
  return (
    <button onClick={handleLike}>
      {optimisticIsLiked ? '❤️' : '🤍'} {optimisticLikes}
    </button>
  );
}

// 2. Shopping cart
function ShoppingCart() {
  const [items, setItems] = useState<CartItem[]>([]);
  
  const [optimisticItems, addOptimistic] = useOptimistic(
    items,
    (current, newItem: CartItem) => [...current, newItem]
  );
  
  async function addToCart(product: Product) {
    const tempItem: CartItem = {
      id: `temp-${Date.now()}`,
      product,
      quantity: 1,
    };
    
    addOptimistic(tempItem);
    
    try {
      const savedItem = await addToCartAPI(product.id);
      setItems(prev => [...prev.filter(i => i.id !== tempItem.id), savedItem]);
    } catch (error) {
      // Reverts
    }
  }
  
  const total = optimisticItems.reduce(
    (sum: number, item: CartItem) => sum + item.product.price * item.quantity,
    0
  );
  
  return (
    <div>
      {optimisticItems.map(item => (
        <CartItem key={item.id} item={item} />
      ))}
      <div>Total: ${total.toFixed(2)}</div>
    </div>
  );
}

// 3. Comment submission
function CommentForm({ postId }: { postId: number }) {
  const [comments, setComments] = useState<Comment[]>([]);
  
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, newComment: Comment) => [newComment, ...current]
  );
  
  async function handleSubmit(text: string) {
    const tempComment: Comment = {
      id: `temp-${Date.now()}`,
      text,
      author: currentUser,
      createdAt: new Date(),
      pending: true,
    };
    
    addOptimistic(tempComment);
    
    try {
      const saved = await postComment(postId, text);
      setComments(prev => [
        saved,
        ...prev.filter(c => c.id !== tempComment.id),
      ]);
    } catch (error) {
      // Reverts, removing temp comment
    }
  }
  
  return (
    <div>
      {optimisticComments.map(comment => (
        <Comment
          key={comment.id}
          comment={comment}
          pending={comment.id.startsWith('temp-')}
        />
      ))}
      <CommentForm onSubmit={handleSubmit} />
    </div>
  );
}

useOptimistic shines in interactive applications where immediate feedback improves UX. Common patterns include likes, cart operations, comments, and real-time updates.

Conclusion

useOptimistic enables modern, responsive UIs by providing instant feedback. Use it for actions likely to succeed, always handle errors gracefully, and enjoy the improved user experience. The automatic rollback ensures data consistency even when operations fail.