Real-Time Zustand State Synchronization Across Browser Tabs in Next.js

Live updates and synchronizing the state across the browser tabs is one of the main functionality for features like cart, wish list.

Storing the state in local storage will persist the data across the browser tabs but there is a catch. The tab changes will not be synchronized, i.e. we can’t see the latest updated state of inactive tab unless we reload the page and the state of any inactive tab will be overridden on page reload with the last updated browser tab state.

Let’s fix this issue in a product cart with Zustand, localStorage using persist(built-in to zustand), Broadcast Channel using shared-zustand along with pagination using Next.js serverless functions with a mock json response structure.

The product will be in the following structure

export interface IProduct {
  "id": string;
  "name": string;
  "price": number;
  "rating": number;
  "image": string;
  "created_at": string;
  "updated_at": string;
}

Next, create a context and context provider to support Next.js SSR

"use client";

import { IProduct } from "@/types/product";
import { createContext, useContext, useRef } from "react";
import { createStore } from "zustand";
import { persist, subscribeWithSelector } from "zustand/middleware";
import { share, isSupported } from "shared-zustand";
import { useStore as useZustandStore } from "zustand";

// Cart Store
interface CartStore {
  products: Array<IProduct>;
  addProduct: (product: IProduct) => void;
  removeProduct: (productId: string) => void;
}

// useCartStore hook to update the state
const useCartStore = createStore<CartStore>()(
  subscribeWithSelector(persist((set) => ({ // subscribes to BroadcastChannel
    products: [],
    addProduct: (product) =>
      set((state) => ({ products: [product, ...state.products] })),
    removeProduct: (productId) =>
      set((state) => ({
        products: state.products.filter((product) => product.id !== productId),
      })),
  }), {
    name: 'cart-storage'
  }))
);

// context of zustand state
const CartContext = createContext<typeof useCartStore | null>(null);

// context provider of zustand state to make it work in the children of Server Side Components
export const CartStoreProvider = ({
  children,
}: {
  children: React.ReactNode;
}): React.JSX.Element => {
  const cartStore = useRef(useCartStore);
  return (
    <CartContext.Provider value={cartStore.current}>
      {children}
    </CartContext.Provider>
  );
};

// useCartBoundStore is the hook with cart context
export const useCartBoundStore = (): CartStore => {
  const context = useContext(CartContext);
  if (!context)
    throw new Error("useCartBoundStore must be used within CartStoreProvider");
  return useZustandStore(context);
};

// BroadcastChannel listens and updates the state across the browser tabs
if ("BroadcastChannel" in globalThis || isSupported()) {
  // share the property "products" of the cart state with other tabs
  share("products", useCartStore);
}

Let’s use this store in the ProductListing component(client component) to update the state and keep the page interactive

'use client'

import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import axios from 'axios'
import Image from "next/image";
import React from "react";
import { IProduct } from "@/types/product";
import { useCartBoundStore } from "@/providers/CartStoreProvider";
import { Basket, Cart } from "@/icons";
import Link from "next/link";


export default function ProductListing() {

  const searchParams = useSearchParams()
  const router = useRouter()

  // bind search params with api query params for pagination
  const currentPage = Number(searchParams.get('current_page'))
  const perPage = Number(searchParams.get('per_page'))
  let current_page = !isNaN(currentPage) && currentPage !== 0 ? currentPage : 1
  let per_page = !isNaN(perPage) && currentPage !== 0 ? perPage : 4
  const fetchProducts = async () => {
    const response = await axios.get(`/api/products?current_page=${current_page}&per_page=${per_page}`)
    return response
  }
  const response = useQuery({
    queryKey: ['products', currentPage, perPage],
    queryFn: fetchProducts
  })

  // items per page
  const perPageOptions = [
    { value: 4, label: 4 },
    { value: 6, label: 6 },
    { value: 8, label: 8 },
    { value: 10, label: 10 },
  ]

 // pagination button handlers
  const handlePrevClick = (): void => {
    router.push(`/?current_page=${current_page - 1}&per_page=${per_page}`)
  }
  const handleNextClick = (): void => {
    router.push(`/?current_page=${current_page + 1}&per_page=${per_page}`)

  }
  //pagination items per page handler
  const handleSelectedPerPageOption = (e: React.ChangeEvent<HTMLSelectElement>): void => {
    router.push(`/?current_page=1&per_page=${e.target.value}`)

  }

  // zustand cart store
  const cartStore = useCartBoundStore()

  return (
    <div className="my-10 space-y-5 max-w-xl mx-auto px-5">
      <h1 className="text-3xl text-center text-green-600 font-bold">Products</h1>
      <div className="flex justify-end">
        <Link href={'/cart'} className="relative inline-block"><Cart className="size-10" /> <div className="text-sm bg-green-500 text-white rounded px-0.5 absolute -top-2 right-0 min-w-4 text-center">{cartStore.products.length}</div></Link>
      </div>
      {response.isLoading ? <div>Loading...</div> :
        <React.Fragment>
          <ul className="grid grid-cols-2 gap-4">
            {
              response.data
                ?.data
                ?.data
                .products
                .map((product: IProduct) => {
                  const isAdded = cartStore.products.find(existingProduct => existingProduct.id === product.id)
                  return <li key={product.id} className="border p-3 space-y-3 relative">

                    <div className="aspect-square relative">
                      <Image src={product.image} alt={`picture of ${product.name}`} fill />
                    </div>
                    <div className="flex justify-between items-center">
                      <div>
                        <h2 className="font-bold text-gray-600">{product.name}</h2>
                        <div className="font-bold">${product.price}</div>
                      </div>

                      {/* add / remove the product from the store */}
                      <div className={`rounded-full p-1.5 flex items-center justify-center cursor-pointer ${!!isAdded ? 'bg-green-600' : 'bg-gray-200'}`} onClick={() => {
                        !isAdded ? cartStore.addProduct(product)
                          : cartStore.removeProduct(product.id)
                      }} >

                        <Basket isSelected={!!isAdded} width={20} height={20} />
                      </div>
                    </div>
                  </li>
                })
            }
          </ul>
          <div className="pt-5 flex flex-col md:flex-row gap-5 justify-between items-center">
            <label className="flex justify-center items-center">

              <select className="py-2.5 px-2 cursor-pointer"
                value={per_page}
                onChange={handleSelectedPerPageOption}
              >
                {perPageOptions
                  .map(option =>
                    <option value={option.value} key={`option-${option.value}`}>
                      {option.label}
                    </option>
                  )}
              </select>
              <div>&nbsp;products per page</div>
            </label>
            <div className="space-x-5">
              <button className='px-5 py-2.5 rounded bg-green-500 text-white disabled:bg-gray-500 cursor-pointer disabled:cursor-not-allowed' onClick={handlePrevClick} disabled={!response.data?.data?.data.pagination.prev_page}>
                Previous
              </button>
              <button className='px-5 py-2.5 rounded bg-green-500 text-white disabled:bg-gray-500 cursor-pointer disabled:cursor-not-allowed' onClick={handleNextClick} disabled={!response.data?.data?.data.pagination.next_page}>
                Next
              </button>
            </div>
          </div>
        </React.Fragment>
      }
    </div>
  );
}

Play around with it at https://cart-app.projects.ahmadbshaik.com/

Access the code at https://github.com/AhmadBShaik/cart-app-with-zustand

0
Subscribe to my newsletter

Read articles from Ahmad Basha Shaik directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ahmad Basha Shaik
Ahmad Basha Shaik

Ahmad is a dynamic software developer with experience in the tech industry, where he honed his skills and expertise in crafting efficient and innovative solutions. His journey began at a startup, where he thrived in the fast-paced environment, tackling challenging projects and contributing to the company's growth.