Implementing Optimistic UI in React.js/Next.js

Implementing Optimistic UI in React.js/Next.js

A smooth and responsive user experience(UX) is important for any modern web application. Users expect quick interactions and immediate feedback after performing an action, even when data needs to be updated on a server. Any delay can lead to frustration from the user. This is where optimistic UI comes in.

Concept of Optimistic UI

Optimistic UI is a technique that prioritizes a positive UX. It updates the UI immediately after a user takes an action, even before the server gets the action. This creates an illusion of instant response, thereby making the application feel faster.

Facebook implements an optimistic UI when you like/unlike a post.

facebook optimistic like action

Without an optimistic UI update, this is what would happen when a user likes a post:

  • User action: The user clicks the like button and the browser sends a request.

  • Server request-response: The server receives the request, processes it, and returns a response.

  • UI update: Facebook handles the server response:

    • If successful, the like button turns blue and the likes count for the post increases.

    • If an error occurs, a toast notification may be displayed.

A user might get frustrated when the server request-response time takes too long.

However, with an optimistic UI, this is what happens:

  • User action: The user clicks the like button and the browser sends a request to the server.

  • Optimistic UI update: The button immediately turns blue and the likes count increases. These happen without waiting for a response from the server.

  • Server request-response: The server receives the request, processes it, and returns a response.

  • Handle response: Facebook handles the server response

    • If the request is successful, the UI remains the same.

    • If there is an error, the UI reverts to the previous state before the user likes the post. The like button turns grey and the likes count is reduced by 1.

With this optimistic UI, users won't feel frustrated even with a slow internet connection. This is because an optimistic UI creates an illusion of a fast response.

We will look at two ways to implement this optimistic UI in React.js/Next.js:

  • Traditional way using the useState hook.

  • Modern way using the useOptimistic hook

Getting Started

To code along, open your terminal and paste the command below to clone the repository:

git clone https://github.com/Olaleye-Blessing/react-nextjs-optimistic-ui.git

The repository contains:

  • A scaffolded Next.js project.

  • A function to fetch todos from the DB.

  • An NPM package, json-server, that simulates REST API.

  • A sleep function to imitate a delay in a network request.

  • A Todo component to render each to-do item.

Despite using Next.js in the repo and this article, the logic applies equally to plain React.js and other React.js frameworks.

The Traditional Way of Doing Optimistic UI with useState

We will start by creating the initial page and necessary components/interfaces:

  • The page that will house the todo creation form and lists.

  • The form component that’ll be used to create new todos.

  • The todos component that renders created todos.

Code comments with numbers will be referenced in the explanations.

Creating The Todos Component

The todos component will accept a todos prop. It will loop through the todos and render a <Todo /> component for each to-do item. Create a app/trad/_components/todos.tsx file and paste the following code:

// app/trad/_components/todos.tsx

import { ITodo } from "@/interfaces/todo";
import Todo from "./todo";

export default function Todos({ todos }: { todos: ITodo[] }) {
  // render todos
  return (
    <ul className="mt-4 flex flex-col items-start justify-start space-y-2">
      {todos.map((todo) => (
        <Todo key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

Creating The Todo Form

The todo form will render a form element that will be used to create a new to-do item. The form will accept no props initially. Create a app/trad/_components/form.tsx file and paste the following code:

// app/trad/_components/form.tsx

import { FormEventHandler } from "react";

interface IAddTodo {}

export default function AddTodo({}: IAddTodo) {
  const add: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault();
  };

  // renders a form to create a to-do
  return (
    <form onSubmit={add} className="flex items-center justify-between">
      <input
        name="todo"
        placeholder="Create a new todo"
        className="w-full mr-4 block py-1 rounded-md text-black px-1"
        // prevent empty value from being submitted
        required
      />
      <button
        type="submit"
        className="bg-green-700 text-white font-bold px-4 py-1 rounded-md"
      >
        Add
      </button>
    </form>
  );
}

The above renders the form we will use to create a new todo. However, we only prevent the form from submitting.

Creating The Main Page

The page will house the <Todos /> and <Form /> components. We will fetch all our to-do items from the database when the page initially mounts. We will then preserve the fetched items in a todos state.

Our todos state will hold both the fetched to-do items and new(optimistic) to-do items. Create a app/trad/page.tsx and paste the following:

// app/trad/page.tsx

"use client";

import { useEffect, useState } from "react";
import { ITodo } from "@/interfaces/todo";
import { getTodos } from "@/lib/todo";
import Form from "./_components/form";
import Todos from "./_components/todos";

export default function Home() {
  const [todos, setTodos] = useState<ITodo[]>([]);

  useEffect(() => {
    (async () => {
      let _todos = await getTodos();

      setTodos(_todos);
    })();
  }, []);

  return (
    <div className="flex flex-col items-center">
      <header>
        <h1 className="font-extrabold text-6xl mb-4">Todos</h1>
      </header>

      <main>
        <Form />
        <Todos todos={todos} />
      </main>
    </div>
  );
}

Our initial layout looks like this after creating the initial components:

If you are coding along, run pnpm run start:dev to start your Next.js and json-server.

Adding Functionalities To Our Components

Next, we’ll create functions to add a new todo and update a todo. We will pass these functions to our form component; you’ll see their usefulness in a jiffy.

Page.tsx

// app/trad/page.tsx

// .... previous code

export default function Home() {
  const [todos, setTodos] = useState<ITodo[]>([]);

  const addNewTodo = (todo: ITodo) => {
    setTodos((prev) => [todo, ...prev]);
  };

  const updateTodo = (oldTodo: ITodo, newTodo: ITodo) => {
    setTodos((prev) =>
      prev.map((todo) => (todo.id === oldTodo.id ? newTodo : todo)),
    );
  };

  // previous useEffect

  return (
    <div className="flex flex-col items-center">
      {/* previous code */}
      <Form addNewTodo={addNewTodo} updateTodo={updateTodo} />
      {/* previous code */}
    </div>
  );
}

Next, we will create a createTodo function. This function sends a request to create a new to-do item to the backend. This function will later be used in our form. Create a app/trad/actions.ts file and paste the following code:

// app/trad/actions.ts
"use server";

import { ITodo } from "@/interfaces/todo";
import { sleep } from "@/lib/sleep";

export const createTodo = async (todo: Omit<ITodo, "id">) => {
  // imitate a delay in the network request
  await sleep(2000);

  try {
    // send the request
    const req = await fetch("<http://localhost:3004/todos>", {
      body: JSON.stringify(todo),
      method: "POST",
    });
    const newTodo: ITodo = await req.json();

    // return the newly created todo
    return newTodo;
  } catch (error) {
    throw error;
  }
};

The “use server” directive is a way to use server actions.

Now we will create our optimistic UI update in our form. Remember, to create our optimistic UI, we need to update the UI immediately after the user clicks the "add button". Only after then, do we send our request to the server.

Our form component will accept the addNewTodo and updateTodo we created in our page component. addNewTodo and updateTodo will be used to add and update a new optimistic to-do item, respectively.

Form.tsx

// app/trad/_components/form.tsx

// previous imports
import { createTodo } from "../actions";

// update our form Props
interface IAddTodo {
  addNewTodo: (todo: ITodo) => void;
  updateTodo: (oldTodo: ITodo, newTodo: ITodo) => void;
}

export default function AddTodo({ addNewTodo, updateTodo }: IAddTodo) {
  const add: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault();
    // comment 1
    const form = e.currentTarget;
    // comment 2
    const body = new FormData(form).get("todo") as string;

    // comment 3
    const id = new Date().getTime();

    // comment 4
    const todo = {
      body,
      completed: false,
    };

    // comment 5
    const optimisticTodo = {
      ...todo,
      id,
    };

    // comment 6
    addNewTodo(optimisticTodo);

    try {
      // comment 7
      let dbTodo = await createTodo(todo);

      // comment 8
      updateTodo(optimisticTodo, dbTodo);

      // comment 9
      form.reset();
    } catch (error) {
      console.log("__ ERROR ___");
    }
  };

  return (
    <form onSubmit={add} className="flex items-center justify-between">
      // previous code...
    </form>
  );
}

A lot is going on in our updated add function:

  • Comment 1: We get our form from the form submit event. This is to be able to reference it when we need it later.

  • Comment 2: We get our todo body from the input field.

  • Comment 3: We create a unique ID for our new todo. This is very important to identify the new todo.

  • Comment 4: We create the todo object that will be sent to the server. Notice we are not passing an ID. It’s the duty of the server to create a new unique ID for new todos.

  • Comment 5: We created our optimistic todo and gave it the unique ID. This optimistic todo will be used when:

    • we want to revert our action.

    • stay in sync with the server response.

  • Comment 6: We perform our optimistic update. The UI gets updated immediately at this point.

  • Comment 7: We send our request to the server and save the new todo in a variable.

  • Comment 8: We need to stay in sync with the server. We update the optimistic todo with the server response. This in turn updates the todo ID to use the ID generated by the server. Assume a user decides to mark the new todo as done, our app will send a wrong ID(the optimistic ID) if we are not in sync with the server.

  • Comment 9: We empty our todo field. This is optional.

react traditional optimistic with usestate

It took more than 2 seconds for the server to respond but our new todo showed up on the screen immediately after we added it.

You might be wondering what happens if an error occurs.

Rollback UI Optimistic Update

We do what Facebook does; we roll back our optimistic UI update.

First, in our Page.tsx, create a function removeTodo, that removes a todo from the current todos. This function will do our rollback in the Form component.

// app/trad/page.tsx

"use client";

// previous code

export default function Home() {
  // previous code

  const removeTodo = (todo: ITodo) => {
    setTodos((prev) => prev.filter((t) => t.id !== todo.id));
  };

  // previous code

  return (
    <div className="flex flex-col items-center">
      <header>
        <h1 className="font-extrabold text-6xl mb-4">Todos</h1>
      </header>

      <main>
        <Form
          addNewTodo={addNewTodo}
          updateTodo={updateTodo}
          // new code
          removeTodo={removeTodo}
        />
        <Todos todos={todos} />
      </main>
    </div>
  );
}

Next, update our createTodo function to throw an error.

actions.ts

// app/trad/actions.ts

// previous code

export const createTodo = async (todo: Omit<ITodo, "id">) => {
  await sleep(2000);

  try {
    throw new Error("Testing...");

    // rest of code
  } catch (error) {
    throw error;
  }
};

Back in our form, we currently log the error to the console. We need to update it to rollback our UI to the previous state. We will update our <Form /> components to accept a removeTodo prop. This prop will be used to remove the optimistic todo from the todos, which in turn roll back our UI.

Form.tsx

// app/trad/_components/form.tsx
// previous code

export default function AddTodo({
  addNewTodo,
  updateTodo,
  removeTodo,
}: IAddTodo) {
  const add: FormEventHandler<HTMLFormElement> = async (e) => {
    // previous code

    try {
      // previous code
    } catch (error) {
      // rollback our UI
      removeTodo(optimisticTodo);
    }
  };

  return {
    /* previous code */
  };
}

react traditional optimistic rollback

As usual, we optimistically added the new todo. We then roll back the update since the server responds with an error.

The complete code for the traditional way can be found in the traditional branch.

useOptimistic: Modern way of creating optimistic UI

The useOptimistic hook offers a more concise way to create optimistic updates. Just like the traditional way, useOptimistic lets you show an optimistic state while an async function is processing.

This hook is only available in React’s Canary. This means it is officially supported but yet to be released.

Syntax of useOptimistic

The syntax of the useOptimistic hook looks like:

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

The useOptimistic hook accepts 2 arguments:

  • state: This is the current state before a user performs any action. In our case, the state is the todos we get from the DB.

  • updateFn: This returns the optimistic update. It takes two arguments, current state and optimistic value. It then returns the optimistic state. In our case, we use this function to return the optimistic todos.

The useOptimistic hook returns an array of 2 items:

  • optimisticState: This is either:

    • the state when the component mounts.

    • or the state when the async function is processing.

  • addOptimistic: This is the function we call to dispatch the optimistic update.

Usage of useOptimistic

Let’s rewrite our traditional way using useOptimistic. We will start by creating our initial components:

  • Page: To house todos.

  • Todos: To house our form and lists.

  • Form: To create a new todo.

  • Lists: To render a list of todos.

Creating The Main Page

We will use a server component to fetch our to-do items from the database. This helps us to revalidate this page(more on this later). Unlike the traditional way, our page won’t hold a todos state as it is not needed. Create a app/modern/page.tsx file and paste the following code:

// app/modern/page.tsx

import { getTodos } from "@/lib/todo";
import Todos from "./_components/todos";

export default async function Page() {
  // We fetch all todos from the database
  const todos = await getTodos();

  return (
    <div className="flex flex-col items-center">
      <header>
        <h1 className="font-extrabold text-6xl mb-4">useOptimistic Todos</h1>
      </header>
      <main>
        <Todos todos={todos} />
      </main>
    </div>
  );
}

We first fetched our todos from the database. We then used the <Todos /> to render them.

Creating The Todos Component

Our <Todos /> component will house both our <Form /> component and list of to-do items. It will accept a todos prop. We will pass this prop to the useOptimistic hook as the initial state. Create a app/modern/_components/Todos.tsx file and paste the following:

// app/modern/_components/Todos.tsx

"use client";

import { ITodo } from "@/interfaces/todo";
import Todo from "./todo";
import { useOptimistic } from "react";
import Form from "./form";

export default function Todos({ todos }: { todos: ITodo[] }) {
  // Comment 1
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (_todos: ITodo[], newTodo: ITodo) => {
      return [..._todos, newTodo];
    },
  );

  return (
    <>
      {/* Comment 2 */}
      <Form addOptimisticTodo={addOptimisticTodo} />
      <ul className="mt-4 flex flex-col items-start justify-start space-y-2">
        {/* Comment 3 */}
        {optimisticTodos.map((todo) => (
          <Todo key={todo.id} todo={todo} />
        ))}
      </ul>
    </>
  );
}
  • Comment 1: We pass the current todos to the useOptimistic hook. Remember, the updateFn(second argument) always returns what the state should look like during an update.

  • Comment 2: We pass the return function to our form. This will be used later in the form component.

  • Comment 3: We render our to-do lists.

Creating The Form Component

Our <Form /> component will accept an addOptimisticTodo prop. This prop will optimistically add a new to-do item to our current to-do lists. Create a app/modern/_components/Form.tsx file and paste the following:

// app/modern/_components/Form.tsx

import { useRef } from "react";
import { createTodo } from "../actions";
import { ITodo } from "@/interfaces/todo";

interface AddTodoProps {
  addOptimisticTodo: (todo: ITodo) => void;
}

export default function Form({ addOptimisticTodo }: AddTodoProps) {
  const formRef = useRef<HTMLFormElement>(null);

  const addTodo = async (data: FormData) => {
    // Comment 1
    const todo = {
      body: data.get("todo") as string,
      completed: false,
    };

    // Comment 2
    addOptimisticTodo(todo);

    try {
      // Comment 3
      await createTodo(todo);

      formRef.current?.reset();
    } catch (error) {
      // Show a toast notification
      console.log("error");
    }
  };

  return (
    <form
      ref={formRef}
      className="flex items-center justify-between"
      action={addTodo}
    >
      // previous code...
    </form>
  );
}
  • Comment 1: We create our todo body. Notice we didn’t have to keep track of this new todo by creating a unique ID. This is because the useOptimistic hook does this out of the box.

  • Comment 2: We dispatch our optimistic function to update the UI.

  • Comment 3: We send our request to the server to create a new todo. This function hasn’t been created yet, we will do so in a jiffy.

Our page looks like this:

modern optimistic layout

Adding Functionalities

Just like the traditional way, we will create a createTodo function that sends a request to the backend. We will then revalidate our page after adding a new to-do item. We will make use of the revalidatePath function provided by Next.js.

actions.ts

// app/modern/actions.ts

"use server";

import { ITodo } from "@/interfaces/todo";
import { sleep } from "@/lib/sleep";
import { revalidatePath } from "next/cache";

export const createTodo = async (todo: ITodo) => {
  // imitate a network delay
  await sleep(2_000);

  try {
    // throw new Error('Testing optimistic'); // un-comment this line to test rollback

    // send a request to create a new todo
    const req = await fetch("<http://localhost:3004/todos>", {
      body: JSON.stringify(todo),
      method: "POST",
    });

    await req.json();

    revalidatePath("/modern");
  } catch (error) {
    throw error;
  }
};

The revalidatePath function helps to refresh the data on a page. The parameter we provide to revalidatePath is the page path. So we refresh our data after a successful response. Without this revalidation, the optimistic todo disappears from the UI when the request is done.

react modern optimistic with useOptimistic

Just like the traditional way, we updated the UI immediately after the user clicked the add button.

The complete code for the modern way can be found in the modern branch.

Benefits and Considerations when using useOptimistic

useOptimistic simplifies the code compared to manual state management. In our simple example, we had to do all these manually:

  • Keep track of the optimistic todo(by generating a unique ID).

  • Update the optimistic todo ID after a successful response.

  • Revert to the previous state in an error case.

useOptimistic does most of these explicitly, although we had to revalidate our page to keep the UI up-to-date. With less code, it will be easier to maintain our code in the future.

While useOptimistic is great, it is only available in React’s Canary and experimental channel. This means useOptimistic is only available in certain React frameworks(like Next.js). It might also have breaking changes in the future.

Essential Tips on Optimistic UI

  • Handle errors: Always handle errors from the server and roll back optimistic updates if necessary. Failure to handle server errors will mislead users.

  • Only use when necessary: Do not use optimistic updates for all user actions. Actions like user authentication shouldn't be updated optimistically until server confirmation is received.

  • Higher Certainty: Only use optimistic updates when there is a 99% chance of the action being successful.

  • Maintain a similar state between UI and server: This is necessary especially when the user performs a create action. For example, in our examples, there will be an error if a user creates a todo and tries to delete it immediately before the server sends the response for the create action. This is because the optimistic todo ID is unknown to the server. One way to prevent this is to disable the todo pending the time the server returns a response.

Conclusion

Effectively implementing optimistic UI can make your React.js/Next.js applications feel faster and more responsive.