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.
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.
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 */
};
}
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 theuseOptimistic
hook. Remember, theupdateFn
(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 theuseOptimistic
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:
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.
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.
Top comments (3)
Thanks for sharing this content.
Interesting read. I have been exploring this concept just recently and you just blew my mind 👏👏👏
I'm glad you liked it.