DEV Community

Cover image for useOptimistic: Build Snappier React UIs with Optimistic Updates
Ugochukwu Nebolisa
Ugochukwu Nebolisa

Posted on

useOptimistic: Build Snappier React UIs with Optimistic Updates

Intro

Have you ever clicked a like button on a social media post and watched it... do nothing for a second? Maybe it shows a loading spinner. Maybe the button just sits there, unresponsive, while your click travels to the server and back.

Now compare that to Instagram or Twitter. You tap the heart icon, and it instantly turns red. No waiting. No spinners. The UI responds immediately, making the app feel lightning-fast even if your network connection isn't.

That is an optimistic update. The UI assumes your action will succeed and updates immediately, while the actual server request happens in the background. If something goes wrong, it quietly rolls back.

React 19 introduced the useOptimistic hook to make this pattern easy to implement. Before this hook, you had to manually manage optimistic state, handle rollbacks, and coordinate between local UI state and server state. It was doable, but messy. With useOptimistic, you get instant UI feedback with just a few lines of code.

In this article, we will build a real example from scratch, compare it side-by-side with the traditional useState approach and learn when you should (and shouldn't) use optimistic updates.

Why useOptimistic?

Before we write build an example, let's understand where optimistic updates shine. These are scenarios where the user action is highly likely to succeed and waiting for server confirmation creates unnecessary friction.

  1. Social Interactions (Likes and Comments):
    When a user likes a post, they expect the heart to turn red instantly. If there is a 500ms round-trip delay to the database, the app feels "heavy." Since a "Like" rarely fails due to business logic, it's the perfect candidate for an optimistic update.

  2. Task Management (To-Do Lists or Kanban):
    Checking off a task or moving a card between columns should feel tactile. Using useOptimistic, the task moves immediately. If the server eventually returns an error (e.g., a lost connection), the hook automatically rolls the UI back to its previous state.

  3. Real-time Messaging:
    Modern chat apps don't show a "sending..." spinner for every single message. They append the message to the chat window immediately with a slightly faded style. Once the server confirms, the style updates to "sent."

The use cases are not limited to the three above. But you will quickly notice that useOptimistic works best when:

  1. The operation is likely to succeed.
  2. The rollback is simple and safe.
  3. The operation is not critical.
  4. Instant feedback will greatly improve UX.

Setting up a project

Now for the fun part, Let's create a fresh React app with TypeScript using Vite. We will keep it minimal, no unnecessary dependencies.

# Create a new Vite project with React and TypeScript
npm create vite@latest useoptimistic-demo -- --template react-ts

# Navigate into the project
cd useoptimistic-demo

# Install dependencies
npm install

# Start the dev server
npm run dev

Your app should now be running at http://localhost:5173.
Enter fullscreen mode Exit fullscreen mode

Open up the project folder in your favorite code editor, then delete the boilerplate files/folders: src/assets, src/App.css, src/index.css(we will use inline style for this). Also delete where they were imported from the remaining files.

Let's build the bare UI. Replace what you have in your src/App.tsx with this:

import { useState } from "react";

const mockPost = {
  id: "post-1",
  author: "Jane Doe",
  content: "Just learned about useOptimistic in React 19!",
  likes: 42,
  isLiked: false,
};

function App() {
  const [post, setPost] = useState(mockPost);

  const handleLike = () => {
    // Logic goes here
  };

  return (
    <div
      style={{
        minHeight: "100vh",
        background: "#f5f5f5",
        padding: "2rem",
      }}
    >
      <div
        style={{
          maxWidth: "600px",
          margin: "0 auto",
        }}
      >
        <h1
          style={{
            marginBottom: "2rem",
            color: "#333",
            fontSize: "2rem",
          }}
        >
          useOptimistic
        </h1>

        <div
          style={{
            background: "white",
            borderRadius: "12px",
            padding: "1.5rem",
          }}
        >
          <div
            style={{
              marginBottom: "1rem",
            }}
          >
            <h3
              style={{
                fontSize: "1rem",
                fontWeight: "600",
                color: "#333",
                margin: 0,
              }}
            >
              {post.author}
            </h3>
          </div>

          <p
            style={{
              color: "#555",
              lineHeight: "1.6",
              marginBottom: "1.5rem",
              fontSize: "0.95rem",
            }}
          >
            {post.content}
          </p>

          <div
            style={{
              display: "flex",
              alignItems: "center",
              gap: "0.5rem",
            }}
          >
            <button
              onClick={handleLike}
              style={{
                background: "transparent",
                border: "none",
                cursor: "pointer",
                fontSize: "1.5rem",
                padding: "0.5rem",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                transition: "transform 0.2s",
              }}
            >
              {post.isLiked ? "❤️" : "🤍"}
            </button>
            <span
              style={{
                color: "#666",
                fontSize: "0.9rem",
                fontWeight: "500",
              }}
            >
              {post.likes} likes
            </span>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

What we have is just UI, nothing happens just yet. Feel free to make changes to the design if you'd like.

Using useState Hook

Before we jump right in to useOptimistic, I want us to have a feeling about how useState alone will handle it. Replace your handleLike function with this.

  const handleLike = async () => {
    // We are simulating a network delay.
    await new Promise((resolve) => setTimeout(resolve, 2000));

    setPost((prev) => ({
      ...prev,
      likes: prev.likes + (prev.isLiked ? -1 : 1),
      isLiked: !prev.isLiked,
    }));
  };

Enter fullscreen mode Exit fullscreen mode

What did you notice? We see that we had to wait for about 2 seconds before the "Like" heart turned red.

With useOptimistic Hook

Now let's adapt our component to use the useOptimistic hook. Replace your App.tsx with the code below:

import { startTransition, useOptimistic, useState } from "react";

const mockPost = {
  id: "post-1",
  author: "Jane Doe",
  content: "Just learned about useOptimistic in React 19!",
  likes: 42,
  isLiked: false,
};

function App() {
  const [post, setPost] = useState(mockPost);
  const [optimisticPost, setOptimisticPost] = useOptimistic(
    post,
    (currentPost, newLikeState: { isLiked: boolean; likes: number }) => ({
      ...currentPost,
      ...newLikeState,
    }),
  );

  const handleLikeAction = async () => {
    const newIsLiked = !optimisticPost.isLiked;
    const newLikes = optimisticPost.likes + (newIsLiked ? 1 : -1);

    startTransition(async () => {
      setOptimisticPost({ isLiked: newIsLiked, likes: newLikes });

      // We are simulating a network delay.
      await new Promise((resolve) => setTimeout(resolve, 2000));

      setPost((prev) => ({
        ...prev,
        likes: prev.likes + (prev.isLiked ? -1 : 1),
        isLiked: !prev.isLiked,
      }));
    });
  };

  return (
    <div
      style={{
        minHeight: "100vh",
        background: "#f5f5f5",
        padding: "2rem",
      }}
    >
      <div
        style={{
          maxWidth: "600px",
          margin: "0 auto",
        }}
      >
        <h1
          style={{
            marginBottom: "2rem",
            color: "#333",
            fontSize: "2rem",
          }}
        >
          useOptimistic
        </h1>

        <div
          style={{
            background: "white",
            borderRadius: "12px",
            padding: "1.5rem",
          }}
        >
          <div
            style={{
              marginBottom: "1rem",
            }}
          >
            <h3
              style={{
                fontSize: "1rem",
                fontWeight: "600",
                color: "#333",
                margin: 0,
              }}
            >
              {post.author}
            </h3>
          </div>

          <p
            style={{
              color: "#555",
              lineHeight: "1.6",
              marginBottom: "1.5rem",
              fontSize: "0.95rem",
            }}
          >
            {post.content}
          </p>

          <div
            style={{
              display: "flex",
              alignItems: "center",
              gap: "0.5rem",
            }}
          >
            <button
              onClick={handleLikeAction}
              style={{
                background: "transparent",
                border: "none",
                cursor: "pointer",
                fontSize: "1.5rem",
                padding: "0.5rem",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                transition: "transform 0.2s",
              }}
            >
              {optimisticPost.isLiked ? "❤️" : "🤍"}
            </button>
            <span
              style={{
                color: "#666",
                fontSize: "0.9rem",
                fontWeight: "500",
              }}
            >
              {optimisticPost.likes} likes
            </span>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

Did you notice the change? Now the like state changes instantly as the user clicks. But let's understand what is happening. Let's analyze the useOptimistic code block.

  const [optimisticPost, setOptimisticPost] = useOptimistic(
    post, // current state
    (currentPost, newLikeState: { isLiked: boolean; likes: number }) => ({ // reducer function
      ...currentPost,
      ...newLikeState,
    }),
  );
Enter fullscreen mode Exit fullscreen mode

The hook receives two values:

  1. The current value/ state (passthrough):
    This is the "source of truth." It is the value that will be returned whenever no optimistic update is in progress.This usually comes from your server data or a useState hook.

  2. An optional reducer function (which takes in two arguments):

  • currentState: The current optimistic state (or the passthrough value if this is the first update).
  • optimisticValue: The "action" or payload you passed to the trigger function. It doesn't have to be a boolean; it can be an object (just like we did), a string, or whatever piece of data you need to calculate the next state.

Then the hook returns two values:

  1. optimisticState (The Result):
    This is the value you use in your JSX. If an update is pending (inside a transition), this is the value calculated by your reducer. If no update is pending, this automatically reverts to the passthrough (the real state).

  2. addOptimistic (The Trigger):
    This is a function you call to kick off the update. It takes one argument: the optimisticValue (the payload) that you want to send to your reducer.

If you notice, you will see that we used a transition (startTransition). Optimistic updates must happen inside a transition or a Server Action, this tells react when to start or stop the optimistic state (basically helps it to track the optimistic state).

Now, one may ask, what happens when a network request fails? Let's find out. Change your handleLikeAction function to this:

 const handleLikeAction = async () => {
    const newIsLiked = !optimisticPost.isLiked;
    const newLikes = optimisticPost.likes + (newIsLiked ? 1 : -1);

    startTransition(async () => {
      setOptimisticPost({ isLiked: newIsLiked, likes: newLikes });

      try {
        // We are simulating a network delay.
        await new Promise((_, reject) => setTimeout(reject, 2000));

        setPost((prev) => ({
          ...prev,
          likes: prev.likes + (prev.isLiked ? -1 : 1),
          isLiked: !prev.isLiked,
        }));
      } catch (error) {
        console.error("Failed to update like status:", error);
      }
    });
  };
Enter fullscreen mode Exit fullscreen mode

You see the magic? When we clicked on like, the heart turns red instantly. But then our promise is rejected (simulating network failure) and immediately the state is reverted to unlike. This then tells you that react always compares the real state of our post object with that of the optimistic state. If the transition finishes and the two states are not the same (because our setPost never ran due to an error), React realizes the "lie" was wrong and snaps the UI back to match the real state. You don't have to manually rollback.

What you can do is to handle your error in the catch block either by showing an error toast or just logging on the console if there's no need to give a visual feedback of that error.

When NOT to Use useOptimistic

While useOptimistic is powerful, it is not appropriate for every situation. Using it incorrectly can lead to poor user experience, data inconsistencies, or even dangerous bugs.

Let's explore scenarios where you should avoid optimistic updates

  1. Financial Transactions (Payments, Transfers, Refunds):
    Imagine clicking "Send 500 (in your local currency)" and the UI immediately shows "Payment sent", but the server rejects it due to insufficient funds. By the time the error appears and the UI rolls back, the user might have already closed the app, thinking the payment went through. Rather, show a clear loading state. Wait for server confirmation before updating the UI.

  2. Destructive Actions (Delete, Archive, Permanent Changes):
    Deleting something optimistically means it disappears from the UI immediately. If the deletion fails, the item reappears confusing users who might think it is a bug or duplicate. User's might even lose trust in your app's reliability.

  3. Complex Business Logic:
    If the success of an action depends on server-side calculations that the client can't predict, skip it. For example, a Promo Code input. You can't optimistically assume a code is valid and apply a discount before the server validates it.

So always check if failure is critical, destructive, or has external side effects. If that's the case, avoid useOptimistic.

Use useOptimistic when Avoid useOptimistic when
Action is highly likely to succeed. Action has a high chance of rejection.
The operation is non-critical (likes, reactions, follows) The operation is critical (payments, deletions, account changes)
The goal is perceived speed. The goal is absolute accuracy.
Server validation is simple. Server validation is complex.

Final Thought

Phew... If you made to the end, congratulations cause it has been a long one. useOptimistic is a small hook with a big impact. It transforms user interactions from slow and waiting to instant and delightful. The best part? It's not even about making your backend faster. It's about being smarter with how you present state changes to users. Start small. Add it to one feature. Feel the difference. Then expand from there. You can get the react's official documentation here useOptimistic react docs. Happy Coding :).

Top comments (1)

Collapse
 
idighekere profile image
Idighekere Udo

Nice read

I will definitely use it someday