In the dynamic landscape of web development, React has constantly evolved, ushering in innovative solutions to enhance user experience. The latest stride in this journey comes in the form of a groundbreaking new hook, useOptimistic
.
Discover how this innovative hook simplifies the implementation of optimistic updates, making your React/Nextjs applications not only faster but also more user-friendly. We’ll explore how to implement optimistic updates using React.js’s new useOptimistic
hook in combination with Next.js 14 server action.
Prerequisites
- Knowledge of Reactjs and Next.js.
- Node.js installed.
- Typescript and Tailwind.
One of the benefits of useOptimistic
is its ability to display the result of a change on the user interface before the changes are effected on the server. This creates a more optimized, responsive application.
A typical web application sends user-submitted information to the server to update the database before displaying the result. This process may result in a delay before the data is shown, leading to a poor user experience. useOptimistic
makes the required changes or updates in the user interface for the user to see before the data is confirmed. In the event of any issue with the data submission to the server, it will revert the changes.
Creating a Job Profile
To demonstrate the functionality of useOptimistic
, I developed a basic web app named Job Profile. This app retrieves and presents essential information about people's job profiles, specifically their names and job titles. Next.js was utilized for client-side rendering and a backend configuration was implemented to communicate with an external API for fetching and sending relevant data.
Using this setup, we can compare the outcomes when useOptimistic
is enabled.
To send data to the backend in a simple form, Server actions will be used. More details about server action can be found on Nextjs.
Note: This project relies on TypeScript and Tailwind CSS. When installing Next.js, be sure to include TypeScript and Tailwind CSS.
Since this is a simple project, we will create the initial form (without the useOptimistic
hook) on the page.tsx
file.
To render the form, here's the JSX code in the return section of the Home
component, after removing unnecessary starter codes and styles.
export default async function Home() {
return (
<main>
<h1 className="text-3xl font-bold text-center">Job profile</h1>
<form className="flex flex-col gap-5 max-w-xl mx-auto p-5">
<input
name="name"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Enter your name..."
/>
<input
name="job"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Your job"
/>
<button className="border bg-green-500 text-white p-2 rounded-md">
Add Job
</button>
</form>
</main>
);
}
API Call for the Job Profile Application
For this project, we will use mock API data obtained from SandAPI. SandAPI assists in managing API endpoints, which is crucial for our application, and in generating dummy data so that we do not need to be concerned about the database of our application.
After creating an account on SandAPI, click on the "Create a New API" button at the top right and fill in the information as shown in the image.
Click on the "Auto-generate" button below to produce fabricated data. This will prompt fields to appear, enabling the user to specify the type of data to be generated. Once the value displayed in the image below has been entered into the designated field, select "Create Endpoint".
Getting data from the API endpoint
Within the Page.tsx
file, an API request must be made to display the dummy data that has been created. As TypeScript is being utilized, it is necessary to specify the type of data being requested, which has been designated as Job
. The data can be retrieved by using the API endpoint from SandAPI.
export interface Job {
sand_id?: number;
name: string;
job: string;
}
//in the Home component, insert this code before the return function in the Home component
("use server");
const res = await fetch("https://api.sandapi.com/user_wFnZRo/jobs", {
cache: "no-cache",
next: {
tags: ["job"],
},
});
const data: Job[] = await res.json();
To display the data, we iterate through each object in the array and present it. We insert this code below the form.
<main>
<h2 className="font-bold p-5">List of Jobs</h2>
<div className="grid gap-x-8 gap-y-4 grid-cols-3">
{data.map((jobs) => {
const { sand_id, name, job } = jobs;
return (
<div key={sand_id} className="p-6 shadow">
<p className="mt-6 text-2xl font-bold text-gray-900 sm:mt-10">
{name}
</p>
<p className="mt-6 text-base font-bold text-gray-600">{job}</p>
</div>
);
})}
</div>
</main>;
The result should be this in the user interface.
Adding New Jobs
The values entered in the form field need to be updated in our list by sending a POST request. To do this, an action attribute must be added to the form tag and a function called addJob
needs to be passed.
Create a new function called addJob
that will allow us to add more items to our dummy data by using the information submitted on the form. The initial step is to add the "use server" since this information is retrieved from the server side of our application.
To add the data from the form, we'll use the get()
callback to retrieve the value of the input element with the name attribute name
or job
.
Next, we create an object named newJob
using the values obtained from the form. This object will be passed as a POST request to the API endpoint. This will enable the addition of new data to our dummy data.
The page.tsx
file should reflect the following structure.
import { revalidateTag } from "next/cache";
export interface Job {
sand_id?: number;
name: string;
job: string;
}
export default async function Home() {
("use server");
const res = await fetch("https://api.sandapi.com/user_wFnZRo/jobs", {
cache: "no-cache",
next: {
tags: ["job"],
},
});
const data: Job[] = await res.json();
//addJob responsible for adding a new Job profile
const addJob = async (e: FormData) => {
"use server";
const name = e.get("name")?.toString();
const job = e.get("job")?.toString();
if (!name || !job) return;
const newJob: Job = {
name: name,
job,
};
await fetch("https://api.sandapi.com/user_wFnZRo/jobs", {
method: "POST",
body: JSON.stringify(newJob),
headers: {
"Content-Type": "application/json",
},
});
revalidateTag("job");
};
return (
<main>
<h1 className="text-3xl font-bold text-center">Job profile</h1>
<form
action={addJob}
className="flex flex-col gap-5 max-w-xl mx-auto p-5"
>
<input
name="name"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Enter your name..."
/>
<input
name="job"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Your job"
/>
<button className="border bg-green-500 text-white p-2 rounded-md">
Add Job
</button>
</form>
<main>
<h2 className="font-bold p-5">List of Jobs</h2>
<div className="grid gap-x-8 gap-y-4 grid-cols-3">
{data.map((jobs) => {
const { sand_id, name, job } = jobs;
return (
<div key={sand_id} className="p-6 shadow">
<p className="mt-6 text-2xl font-bold text-gray-900 sm:mt-10">
{name}
</p>
<p className="mt-6 text-base font-bold text-gray-600">{job}</p>
</div>
);
})}
</div>
</main>
</main>
);
}
The inserted values in the form will be displayed in the rendering section.
Without the useOptimistic
hook integrated into the form, the Job Profile app would rely on the traditional method of updating the user interface only after the server has confirmed the changes. When a user creates a new job profile, the app must wait for the server to process the update and confirm it before displaying the revised information. As a result, there is a slight delay in adding form input to the list.
Optimizing the display using useOptimistic
To improve the user experience of the app, we can use theuseOptimistic
hook. To implement this, it is necessary to create a file named form.tsx
.
All the JSX elements from page.tsx
can be transferred to form.tsx
, and the essential data gotten from the API can be passed as props, denoted as job
. Furthermore, the addJob
function should be removed as it will be configured in the form.tsx
file.
To cache the data transmitted to the server, a function named validateData
needs to be created. This function will invoke the server action named revalidatePath
, and we can pass this validateData
as props in the form.tsx
file.
Upon implementing these adjustments, thepage.tsx
file should reflect the following structure.
import { revalidatePath } from "next/cache";
import Form from "./components/form";
import React from "react";
export interface Job {
sand_id?: number;
name: string;
job: string;
}
//the addJob is moved to form.tsx
export default async function Home() {
("use server");
const res = await fetch("https://api.sandapi.com/user_wFnZRo/jobs", {
cache: "no-cache",
next: {
tags: ["job"],
},
});
const data: Job[] = await res.json();
//The validateData function calls revalidatePath to purge cached data for a specific path.
const validateData = async () => {
"use server";
revalidatePath("/jobs");
};
//We add the Form component that houses the form section of the app in Form.tsx.
// Next, we include the two props, "jobs" and validateData.
return (
<main>
<Form jobs={data} validateData={validateData} />
</main>
);
}
Both the jobs
props and validateData
functions will be destructured in the form.tsx
file.
Importing useOptmistic to the app
In the form.tsx
, we first destructure the useOptimistic
hook from React.
"use client";
import { useOptimistic } from "react";
The useOptimistic
hook has two values - the optimistic state (called optimisticJob
) and a function (called addOptimisticJob
). The jobs
props from page.tsx
are passed to the useOptimistic
hook to track any changes in the server's data and update it accordingly. By passing the props to useOptimistic
, we can now replace the old data with the new optimisticJob
variable.
"use client";
import { useOptimistic } from "react";
type JobType = {
sand_id?: number;
name: string;
job: string;
};
type JobProps = {
jobs: JobType[];
validateData: any;
};
export default function Form({ jobs, validateData }: JobProps) {
const [optimisticJob, addOptimisticJob] = useOptimistic(
jobs,
(state, newJob: JobType) => {
return [...state, newJob];
},
);
return (
<main>
<h1 className="text-3xl font-bold text-center">Job profile</h1>
<form className="flex flex-col gap-5 max-w-xl mx-auto p-5">
<input
name="name"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Enter your name..."
/>
<input
name="job"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Your job"
/>
<button className="border bg-green-500 text-white p-2 rounded-md">
Add Job
</button>
</form>
<h2 className="font-bold p-5">List of Jobs</h2>
<div className="grid gap-x-8 gap-y-4 grid-cols-3">
{/* We then change data to optimisticJob */}
{optimisticJob.map((singleJob) => {
const { sand_id, name, job } = singleJob;
return (
<div key={sand_id} className="p-6 shadow">
<p className="mt-6 text-2xl font-bold text-gray-900 sm:mt-10">
{name}
</p>
<p className="mt-6 text-base font-bold text-gray-600">{job}</p>
</div>
);
})}
</div>
</main>
);
}
Updating the User Interface
For updating values, we create the addJob
function. This is responsible for submitting our data to the database.
It's essential to call the validateData
function inside the addJob
function to clear cached data for a particular path. The form.tsx
file should have this code structure to ensure everything works as expected.
"use client";
import { useOptimistic } from "react";
type JobType = {
sand_id?: number;
name: string;
job: string;
};
type JobProps = {
jobs: JobType[];
validateData: any;
};
export default function Form({ jobs, validateData }: JobProps) {
const [optimisticJob, addOptimisticJob] = useOptimistic(
jobs,
(state, newJob: JobType) => {
return [...state, newJob];
},
);
//the addJob function
const addJob = async (e: FormData) => {
const name = e.get("name")?.toString();
const job = e.get("job")?.toString();
if (!name || !job) return;
const formInput = {
name,
job,
};
addOptimisticJob(formInput);
await fetch("https://api.sandapi.com/user_wFnZRo/jobs", {
method: "POST",
body: JSON.stringify(formInput),
headers: {
"Content-Type": "application/json",
},
});
await validateData();
};
return (
<main>
<h1 className="text-3xl font-bold text-center">Job profile</h1>
<form
action={addJob}
className="flex flex-col gap-5 max-w-xl mx-auto p-5"
>
<input
name="name"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Enter your name..."
/>
<input
name="job"
type="text"
className="border border-gray-300 p-2 rounded-md"
placeholder="Your job"
/>
<button className="border bg-green-500 text-white p-2 rounded-md">
Add Job
</button>
</form>
<h2 className="font-bold p-5">List of Jobs</h2>
<div className="grid gap-x-8 gap-y-4 grid-cols-3">
{/* We then change data to optimisticJob */}
{optimisticJob.map((singleJob) => {
const { sand_id, name, job } = singleJob;
return (
<div key={sand_id} className="p-6 shadow">
<p className="mt-6 text-2xl font-bold text-gray-900 sm:mt-10">
{name}
</p>
<p className="mt-6 text-base font-bold text-gray-600">{job}</p>
</div>
);
})}
</div>
</main>
);
}
By examining the code, it becomes evident that we invoked the addOptimisticJob
function on the data received from the form before dispatching it to the server. This approach initially updates the value in the UI component and then sends it to the backend for further processing. In the event of a failed update, the item gets removed from the UI.
The result should be this:
As a result of this process, the data is displayed to the user immediately before being saved to the database, as depicted in the image above.
Conclusion
By demonstrating the functionality of useOptimistic
in the Job Profile app, it becomes clear that this hook significantly enhances the user experience by eliminating delays and creating a more fluid and responsive interface for users to interact with.
In the event of any issues with the data submission to the server, useOptimistic
also ensures that the changes made on the user interface are reverted, maintaining data integrity and providing a safety net for potential errors.
Top comments (0)