DEV Community

Kenneth Nnopu
Kenneth Nnopu

Posted on

Opmistic Update In Nextjs 14

Hooks in react

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

  1. Syntax
  2. Project Structure
  3. How api update errors are handled
  4. 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
    }
  );
Enter fullscreen mode Exit fullscreen mode

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

project sections

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 [];
  }
};

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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)