Implementing Optimistic Updates in Next.js using React 18's `useOptimistic` Hook

Implementing Optimistic Updates in Next.js using React 18's `useOptimistic` Hook

In this article, I'll guide you through the concept of optimistic updates in Next.js using React 18. We will build an interactive tweet-like component and enhance the user experience with the help of the useOptimistic hook. Let's get started!

What are Optimistic Updates?

The term "optimistic updates" refers to an application behaviour in which we don't need to wait for asynchronous operations to complete before seeing their results. It's a little trick – the operation is performed in the background while the interface is updated immediately.

Optimistic Updates and UX

Let's assume we're building a social media platform. When a user likes a post, they have to wait a second or two for the server to confirm that the like was successful. This results in a poor user experience (UX). Can we improve it somehow?

Yes! We can apply Optimistic Updates and immediately update the like counter after the user clicks the button. In the background, we'll send the request to the server. It makes the post appear as if the server operation was immediately successful without waiting for its completion.

Optimistic Updates, when they're successful

Additionally, when the server update is ultimately successful, we could also inform the user about it. Depending on the specific use case, app, and design, we can do it in several ways:

  • While the asynchronous operation is in progress, we display a tiny spinner (animation). Either near the "like" button or, for example, in the lower right corner of the screen. When the operation is successful, the spinner simply disappears.

  • We perform an optimistic update, but the view slightly differs from the final one. For instance, the like counter increases immediately, but it's slightly greyed out until the response from the server arrives.

  • After the server query is completed, we display a message to the user. We can do this in different manners, depending on the importance of the operation being performed. Toasts are often used in this case.

Optimistic Updates, when they fail

Most user actions will be successful. However, we cannot completely ignore the possibility that liking a post might fail for some reason. It could be a temporary loss of the Internet connection or an intermittent server outage. What then?

A lot depends on the seriousness of the situation and the operation being performed by the user. Going back to the example of likes, we can simply undo the optimistic update and decrease the like counter. However, for more important actions like sending an email or deleting a document, we should consider displaying a notification and informing the user about the consequences or the need to take action.

React.js Implementation

useOptimistic in React.js and Next.js 14

In early May 2023, the React team released experimental support for the built-in hook called useOptimisticState, which was later renamed to a simpler useOptimistic. Its use cases include implementing, well, optimistic updates, but not just that. Essentially, useOptimistic allows for the implementation of any "pending" state, that doesn't necessarily have to be related to sending requests to a server.

Today, the hook is available in canary and experimental React releases as well as Next.js 13 and 14. This is where we'll be using it.

Let's get started with Next.js 14

We're beginning by creating a new project in Next.js 14:

pnpm create next-app use-optimistic

Now we just need to answer a few simple questions and we're good to go!

Naïve Implementation

Let's start with a naïve implementation. Let's assume that there are three functions used for operating on likes:

  1. getLikes()

  2. like()

  3. dislike()

In the main component, we fetch the number of likes and information on whether we have already liked the current post or not:

const { likes, isLiked } = await getLikes();

Next, we pass this information to the LikesCounter component:

<LikesCounter
  likes={likes}
  isLiked={isLiked}
  onLiked={async () => {
    "use server";
    revalidatePath("/");
  }}
/>

The simplest implementation of this component is just a basic form with a button:

<form action={async () => {
  if (isLiked) {
    await dislike();
  } else {
    await like();
  }
  await onLiked();
}}>
<button type="submit" aria-label={isLiked ? "Dislike" : "Like"}>
  {likes}
</button>

Unfortunately, as anticipated, this doesn't work very well. After clicking the button, it takes a few seconds before the counter updates. Moreover, we should also handle an intermediate state – waiting for the response from the API – and during this time, ignore further button clicks.

Implementation with useOptimistic

useOptimistic is a hook that takes two arguments: the initial state (e.g. from the server) and a reducer – a function that takes the state and an action and returns a new state. useOptimistic returns a tuple with the optimistic state and a function to update it.

An example usage looks like this:

const [optimisticLikes, setOptimisticLikes] = useOptimistic(
  likes,
  (likes, action: "LIKE" | "DISLIKE") => {
    if (action === "LIKE") {
      return likes + 1;
    } else {
      return likes - 1;
    }
  },
);
💡
We're doing a bit of ars gratia artis for educational purposes – after all, we have only two actions, so the entire reducer could be vastly simplified. We could even be tempted to just pass 1 and -1 to it instead of action. Bear with me.

Now we need to modify the function for updating likes. Before liking a post, we call setOptimisticLikes("LIKE"), and if it was already liked, we use setOptimisticLikes("DISLIKE"). Additionally, instead of likes, we use optimisticLikes for displaying. The entire code listing is shown below, although it's worth noting that not everything works as it should just yet…

<form action={async () => {
  if (isLiked) {
    setOptimisticLikes("DISLIKE");
    await dislike();
  } else {
    setOptimisticLikes("LIKE");
    await like();
  }
  await onLiked();
}}>
<button type="submit" aria-label={isLiked ? "Dislike" : "Like"}>
  {optimisticLikes}
</button>

Rapidly clicking the button now causes the counter to decrease: 20, 19, 18, 17, 16, 15, 14… Additionally, we never update the value of isLiked. We need to modify our state and reducer slightly to fix that:

const [optimisticState, setOptimisticLikes] = useOptimistic(
  { likes, isLiked },
  (state, action: "LIKE" | "DISLIKE") => {
    if (action === "LIKE") {
      return { likes: state.likes + 1, isLiked: true };
    } else {
      return { likes: state.likes - 1, isLiked: false };
    }
  },
);

Now, instead of a simple state, we have an object with two properties: isLiked and likes – which correspond to those coming from the server. Another important change is updating isLiked and using it in the components below:

<form action={async () => {
  if (optimisticState.isLiked) {
    setOptimisticLikes("DISLIKE");
    await dislike();
  } else {
    setOptimisticLikes("LIKE");
    await like();
  }
  await onLiked();
}}>
  <button
    type="submit"
    aria-label={optimisticState.isLiked ? "Dislike" : "Like"}>
    {optimisticState.likes}
  </button>
</form>

Now, quickly clicking the button causes the counter to toggle up and down – just as we would expect!

Actions Queueing

You might now be wondering, "If I click the button quickly 10 times, what will be the final state on the server side?" Don't worry, React.js and Next got you covered and take care of the correctness for you. Consecutive clicks cause an immediate interface change (optimistically), but the server requests are queued and sent one by one. There's no risk that rapid button clicking will result in requests being sent out of order.

We can observe this behaviour precisely in the Network tab in devtools. We see a beautiful waterfall of requests – they are not executed in parallel but one after another.

State rebasing

An interesting and yet underrated behaviour of useOptimistic is recalculating (rebasing) of the optimistic state when the source state changes. Imagine a scenario: a post has 20 likes, and we like it. The counter optimistically changes to 21 immediately. However, while our request is being executed to the server, an update to the number of likes arrives: it turns out there are now 44. What should the optimistic interface display?

In many simple implementations, it would show 44. However, I expect 45 (the number from the server + our like "in flight"). Fortunately, React.js saves the day again and takes care of this for us. We don't even have to do anything!

Summary

Undoubtedly, many of you have implemented optimistic updates on your own, but the complexity of this problem and the numerous edge cases to consider make it a challenging task. Fortunately, useOptimistic is built into React.js and significantly simplifies the implementation of applications that adhere to good UX principles.

Sources

  1. https://github.com/HyperFunctor/optimistic-updates

  2. https://kongresnextjs.pl/

  3. https://www.nextjsmasters.pl/

  4. https://github.com/facebook/react/pull/26740

  5. https://github.com/facebook/react/pull/26772

  6. https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#optimistic-updates

Did you find this article valuable?

Support Michał Miszczyszyn by becoming a sponsor. Any amount is appreciated!