Published on

React 19: Mastering the useOptimistic Hook for Better UX

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

React 19: Mastering the useOptimistic Hook for Better UX

React 19 introduces several groundbreaking features that enhance developer experience and application performance. Among these, the useOptimistic hook stands out as a powerful tool for creating responsive user interfaces. This hook enables optimistic UI updates, providing instant feedback to users while handling the underlying data synchronization seamlessly.

What is Optimistic UI?

Optimistic UI is a design pattern where the interface immediately reflects user actions before the server confirms them. This creates a perception of speed and responsiveness, even when network requests are involved.

Traditional Approach vs Optimistic UI

Traditional Approach:

const [likes, setLikes] = useState(0);
const [loading, setLoading] = useState(false);

const handleLike = async () => {
  setLoading(true);
  try {
    await api.likePost(postId);
    setLikes((prev) => prev + 1);
  } catch (error) {
    // Handle error
  } finally {
    setLoading(false);
  }
};

With Optimistic UI: The UI updates immediately, then reconciles with server response.

Introducing useOptimistic Hook

The useOptimistic hook in React 19 simplifies optimistic updates by managing the state transitions automatically. It takes an initial state and a reducer function, similar to useReducer.

Basic Syntax

const [optimisticState, addOptimistic] = useOptimistic(
  initialState,
  (currentState, optimisticValue) => {
    // Return the new optimistic state
    return { ...currentState, ...optimisticValue };
  }
);

Practical Examples

Like Button with Optimistic Updates

import { useOptimistic } from "react";

function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (currentLikes, newLike) => currentLikes + 1
  );

  const handleLike = async () => {
    // Optimistically update the UI
    addOptimisticLike(1);

    try {
      await api.likePost(postId);
    } catch (error) {
      // Revert the optimistic update on error
      addOptimisticLike(-1);
    }
  };

  return <button onClick={handleLike}>❤️ {optimisticLikes} likes</button>;
}

Shopping Cart with Optimistic Updates

function ShoppingCart({ items, onUpdateCart }) {
  const [optimisticCart, addOptimisticItem] = useOptimistic(items, (currentCart, newItem) => [
    ...currentCart,
    newItem,
  ]);

  const addToCart = async (product) => {
    // Optimistically add item to cart
    addOptimisticItem(product);

    try {
      await api.addToCart(product.id);
      onUpdateCart(); // Refresh actual cart data
    } catch (error) {
      // Remove the optimistic item on error
      addOptimisticItem(product, true); // Remove mode
    }
  };

  return (
    <div>
      <h2>Cart ({optimisticCart.length} items)</h2>
      {optimisticCart.map((item, index) => (
        <CartItem key={index} item={item} />
      ))}
    </div>
  );
}

Form Submission with Optimistic Updates

function CommentForm({ postId, onCommentAdded }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    [],
    (currentComments, newComment) => [...currentComments, newComment]
  );

  const handleSubmit = async (formData) => {
    const comment = {
      id: Date.now(), // Temporary ID
      text: formData.get("comment"),
      author: "You",
      timestamp: new Date().toISOString(),
      pending: true, // Mark as pending
    };

    // Add optimistic comment
    addOptimisticComment(comment);

    try {
      const savedComment = await api.addComment(postId, comment);
      onCommentAdded(savedComment);
    } catch (error) {
      // Remove the optimistic comment
      addOptimisticComment(comment, true);
    }
  };

  return (
    <form action={handleSubmit}>
      <textarea name="comment" placeholder="Add a comment..." />
      <button type="submit">Post Comment</button>

      {/* Display optimistic comments */}
      {optimisticComments.map((comment) => (
        <div key={comment.id} className={comment.pending ? "opacity-50" : ""}>
          <strong>{comment.author}</strong>: {comment.text}
          {comment.pending && <span>(sending...)</span>}
        </div>
      ))}
    </form>
  );
}

Advanced Patterns

Handling Multiple Optimistic Updates

function TaskList({ tasks, onUpdateTask }) {
  const [optimisticTasks, updateOptimisticTask] = useOptimistic(
    tasks,
    (currentTasks, { id, updates }) => {
      return currentTasks.map((task) => (task.id === id ? { ...task, ...updates } : task));
    }
  );

  const toggleTask = async (taskId) => {
    const task = optimisticTasks.find((t) => t.id === taskId);
    const newStatus = !task.completed;

    // Optimistically update
    updateOptimisticTask({ id: taskId, updates: { completed: newStatus } });

    try {
      await api.updateTask(taskId, { completed: newStatus });
    } catch (error) {
      // Revert on error
      updateOptimisticTask({ id: taskId, updates: { completed: !newStatus } });
    }
  };

  return (
    <ul>
      {optimisticTasks.map((task) => (
        <li
          key={task.id}
          onClick={() => toggleTask(task.id)}
          className={task.completed ? "line-through" : ""}
        >
          {task.title}
        </li>
      ))}
    </ul>
  );
}

Optimistic Updates with Server State Management

import { useOptimistic } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";

function PostList() {
  const { data: posts } = useQuery(["posts"], fetchPosts);

  const [optimisticPosts, addOptimisticPost] = useOptimistic(
    posts || [],
    (currentPosts, newPost) => [newPost, ...currentPosts]
  );

  const createPostMutation = useMutation({
    mutationFn: createPost,
    onSuccess: (newPost) => {
      // Server response received, update with real data
      queryClient.setQueryData(["posts"], (old) => [newPost, ...old]);
    },
    onError: () => {
      // Remove optimistic post on error
      addOptimisticPost(null, true);
    },
  });

  const handleCreatePost = (postData) => {
    const optimisticPost = {
      id: `temp-${Date.now()}`,
      ...postData,
      createdAt: new Date().toISOString(),
      optimistic: true,
    };

    addOptimisticPost(optimisticPost);
    createPostMutation.mutate(postData);
  };

  return (
    <div>
      {optimisticPosts.map((post) => (
        <Post key={post.id} post={post} isOptimistic={post.optimistic} />
      ))}
    </div>
  );
}

Best Practices

1. Provide Visual Feedback

Always indicate when an action is optimistic:

function OptimisticButton({ children, onClick, isOptimistic }) {
  return (
    <button
      onClick={onClick}
      disabled={isOptimistic}
      className={isOptimistic ? "cursor-not-allowed opacity-50" : ""}
    >
      {isOptimistic ? "Saving..." : children}
    </button>
  );
}

2. Handle Errors Gracefully

const [optimisticState, addOptimistic] = useOptimistic(
  initialState,
  (currentState, { action, payload, revert }) => {
    if (revert) {
      // Revert to previous state
      return currentState.previousState;
    }

    // Store current state for potential revert
    const newState = { ...currentState, ...payload, previousState: currentState };

    return newState;
  }
);

3. Use Appropriate Timing

// For fast actions (likes, favorites)
const [optimisticLikes, addLike] = useOptimistic(likes, increment);

// For slower actions (form submissions)
const [optimisticData, updateData] = useOptimistic(
  data,
  (current, update) => ({ ...current, ...update }),
  { timeout: 3000 } // Custom timeout
);

4. Combine with Error Boundaries

class OptimisticErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // Log error and potentially revert optimistic updates
    this.props.onError?.(error);
  }

  render() {
    if (this.state.hasError) {
      return <div>Something went wrong. Please refresh the page.</div>;
    }

    return this.props.children;
  }
}

Performance Considerations

Memory Management

// Clean up optimistic updates after server confirmation
useEffect(() => {
  const cleanup = setTimeout(
    () => {
      // Remove temporary optimistic items after 5 minutes
      setOptimisticItems((items) => items.filter((item) => !item.temp));
    },
    5 * 60 * 1000
  );

  return () => clearTimeout(cleanup);
}, [optimisticItems]);

Debouncing Optimistic Updates

function useDebouncedOptimistic(value, delay = 300) {
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setOptimisticValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return optimisticValue;
}

Migration from React 18

Before (React 18)

const [state, setState] = useState(initialState);
const [optimisticState, setOptimisticState] = useState(initialState);

const updateOptimistic = (update) => {
  setOptimisticState(update);
  // Manual server sync and error handling
};

After (React 19)

const [optimisticState, updateOptimistic] = useOptimistic(initialState, (currentState, update) => ({
  ...currentState,
  ...update,
}));

// Automatic state management with built-in error handling

Real-World Use Cases

1. Social Media Interactions

  • Like/unlike posts
  • Follow/unfollow users
  • Bookmark content
  • Share posts

2. E-commerce

  • Add to cart
  • Update quantities
  • Apply coupons
  • Wishlist management

3. Collaborative Editing

  • Real-time document updates
  • Comment threads
  • Task assignments
  • Status changes

4. Form Submissions

  • Contact forms
  • Registration forms
  • Settings updates
  • Profile changes

Testing Optimistic Updates

// Test optimistic updates
test("optimistic like updates immediately", () => {
  const { result } = renderHook(() => useOptimistic(0, increment));

  act(() => {
    result.current[1](1); // addOptimistic
  });

  expect(result.current[0]).toBe(1); // optimisticState
});

// Test error handling
test("reverts optimistic update on error", async () => {
  const mockApi = jest.fn().mockRejectedValue(new Error("API Error"));

  // Test implementation
});

Conclusion

The useOptimistic hook in React 19 represents a significant improvement in building responsive user interfaces. By providing instant feedback and handling state reconciliation automatically, it enables developers to create applications that feel fast and responsive, even with network latency.

Key benefits include:

  • Better UX: Instant visual feedback
  • Simplified Code: Less boilerplate for optimistic updates
  • Automatic Reconciliation: Built-in error handling and state management
  • Performance: Reduced perceived latency

As you migrate to React 19, consider using useOptimistic for any user interactions that would benefit from immediate feedback. The hook works seamlessly with existing React patterns and can significantly improve the perceived performance of your applications.

Remember to always provide clear visual feedback for optimistic updates and handle errors gracefully to maintain user trust. With proper implementation, optimistic UI can make your applications feel lightning-fast while maintaining data consistency.

I build and scale reliable production systems. Open to full-time and freelance work with U.S.-based teams that value ownership and execution.

Got something in mind?

Book a Discovery Call