Ever been in a situation where you take an action say create a new user and the response takes too long to come back because of a number of reason that can include a slow api or a slow network or any number of reason?
React or in our case Nextjs which is a react framework comes to the rescue with the useOptimistic
hook. It has always been frustrating having to wait for an update to finish before seeing the result of the update operation.
The useOptimistic hook has been available in react canary only but finally comes to core react and Nextjs. Packages like react-query achieves this but I'm happy it's finally built into the framework
Table of content
- Syntax
- Project Structure
- How api update errors are handled
- Conclusion.
1). Syntax
const [optimisticProducts, setOptimisticProducts] = useOptimistic(
state, // the current state
(currentState, optimisticState) => {
// optimistic state refers to the data we want to update
// next combine the current state and optimistic state and return the result
}
);
The useOptimistic hook follows the reducer pattern, it takes in a state value and a callback function that specifies how the data should be manipulated
2). Project Structure
For our demonstration we attempt to build the a simple form that adds products for an e-commerce website, the page has 2 sections
1) The form that adds the product
2) A grid that shows all added products
A full link to the source code would be at the end of this article but for now I'll list out some the file and folder structure we would be creating
-src
--app
---components
----admin.tsx
----productCard.tsx
----productForm.tsx
----products.tsx
---actions.ts
---layout.tsx
----page.tsx
Files Work-through
- Page.tsx file
import Link from "next/link";
import Admin from "./components/admin";
import { getProducts } from "./actions";
export default async function Home() {
const products = await getProducts();
return (
<main className="bg-black">
<header className="border h-14 items-center flex mb-6 p-4 justify-between">
<Link href="/" className=" text-xl">
Bettys Place
</Link>
<input
type="search"
name="search"
id=""
className="border w-[60%] max-w-[700px] rounded-xl py-1 px-6 text-black"
placeholder="Search for products"
/>
<div></div>
</header>
<Admin products={products} />
</main>
);
}
The project has one route which is the home route of the application. Here the data fetching call happens to get all the saved products and pass it as props to the admin component. This route serves as the main entry point for the application and provides a seamless user experience for managing products.
- Actions.ts
"use server";
import { CreateProductRequest, Product } from "@/lib/types";
import { sleep } from "./helpers";
import { revalidatePath } from "next/cache";
export const createProducts = async (input: CreateProductRequest) => {
try {
await sleep(3000);
await fetch("http://localhost:3000/api/products", {
method: "POST",
body: JSON.stringify({
name: input.name,
price: input.price,
image: "https://dummyimage.com/100",
}),
});
return true;
} catch (error) {
return false;
} finally {
revalidatePath("/");
}
};
export const getProducts = async () => {
try {
const response = await fetch("http://localhost:3000/api/products");
const data = await response.json();
return data;
} catch (error) {
return [];
}
};
The server action contains to function createProduct
and getProduct
make api call to a nextjs route to save and retrieve data as required. For now we ignore the revalidatePath call as it would make sense later on. The createProduct
function sends a POST request to the nextjs route to save the product data, while the getProduct
function sends a GET request to retrieve the product data as needed. These functions are essential for handling the communication between the server and the nextjs route, ensuring that the necessary data is saved and retrieved accurately.
- ProductForm Component
"use client";
import React, { RefObject } from "react";
type ProductFormProps = {
onCreateClick: (data: FormData) => void;
ref: RefObject<HTMLFormElement>;
};
const ProductForm = ({ onCreateClick, ref }: ProductFormProps) => {
return (
<div className="flex items-center justify-center">
<form
className=" w-[50%] flex flex-col items-center"
action={onCreateClick}
ref={ref}
>
<div className="flex flex-col gap-2 mb-4">
<label htmlFor="">Product Name: </label>
<input
type="text"
name="name"
className=" rounded w-[400px] h-10 text-black px-4"
/>
</div>
<div className="flex flex-col gap-4">
<label htmlFor="">Price: </label>
<input
name="price"
type="number"
className=" rounded w-[400px] h-10 text-black px-4"
/>
</div>
<button className="border p-4 rounded-xl mt-6 w-[200px] bg-green-600">
Create
</button>
</form>
</div>
);
};
export default ProductForm;
This form section take a ref used to reference the form and a client side action to handle the submit action of the form
- The Products Component
import { Product } from "@/lib/types";
import ProductCard from "./productCard";
type ProductsSectionProps = {
products: Product[];
};
const Products = ({ products }: ProductsSectionProps) => {
return (
<div className="w-full gap-4 justify-center flex flex-wrap">
{products.map((product, i) => (
<ProductCard key={i} product={product} />
))}
</div>
);
};
export default Products;
export type Product = {
id: string;
name: string;
price: string;
image: string;
};
This takes the products array mapping through it to render the data in a card component
- The Admin Component
"use client";
import { useOptimistic, useRef } from "react";
import ProductForm from "./productForm";
import Products from "./productsSection";
import { Product } from "@/lib/types";
import { createProducts } from "../actions";
type AdminProp = {
products: Product[];
};
const Admin = ({ products }: AdminProp) => {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticProducts, setOptimisticProducts] = useOptimistic(
products,
(currentValue: Product[], optimisticValue: Product) => {
return [...currentValue, optimisticValue];
}
);
const handleCreateProducts = async (data: FormData) => {
const name = data.get("name");
const price = data.get("price");
if (typeof name !== "string" || !name) return;
if (typeof price !== "string" || !price) return;
const product = {
id: Math.random().toString(),
name,
price,
image: "https://dummyimage.com/100",
};
/**
The setOptimisticProduct is called before the blocking
createProducts so it optimistically updates the UI
before making a call to createProducts
**/
setOptimisticProducts(product);
formRef.current?.reset();
await createProducts(product);
};
return (
<>
<div>
<h2 className="text-center text-2xl mb-6">Create Products</h2>
<ProductForm onCreateClick={handleCreateProducts} ref={formRef} />
</div>
<div className="mt-20">
<h2 className="text-center text-2xl mb-6">Products</h2>
<Products products={optimisticProducts} />
</div>
</>
);
};
export default Admin;
The optimisticProduct
and the setOptimisticProducts
is where the magic happen
-
optimisticProduct
is the returned product from the optimistic update -
setOptimisticProducts
takes in the optimistic value and fires the reducer function to combine the current state and the optimistic value for instant update
when we receive data from the api call, revalidatePath
function is called in the createProduct server action that tells nextjs to dump the cache and refetch the data which now becomes the initial value of the useOptimistic
hook since the data is passed as prop to the Admin component.
3). How api update errors are handled
A common question that might be on your mind is what happens if the api fails to update the data? won't the UI contains incorrect data?
The React team took this into account, since the call to get products happens at the top level where the response (list of products) are passed down as props, immediately the revalidatePath call is made nextjs refetches the products from the api. This will swap the existing data with the new data that does not contain the failed product thus it would be removed from the UI
4). Conclusion
All these comes together to make up a basic application that implements the optimistic UI, No matter how slow an endpoint is data would always be updated in the UI instantly. The full source code to the project can be found here
Top comments (0)