Implement Zustand for State Management
![Jack Herrington](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fjack-herrington.aa983e85.jpg&w=96&q=75)
Install Zustand by running the following command:
npm install zustand
Because we can't create a global variable when using App Router, we need to use React Context to teleport the hook wherever it is needed.
Let's start by creating the cart provider.
Creating the Cart Provider
Inside of the app
directory, create a new store
directory then a file named CartProvider.tsx
.
At the top of the file, start by specifying this will be a client component, then import the necessary functions for React context. Next we'll import create
from Zustand, which will allow us to create a Zustand hook that can be passed in a context. We'll also import the Cart
type from @/api/types
:
"use client";
import { useState, createContext, useContext } from "react";
import { create } from "zustand";
import { Cart } from "@/api/types";
Remember, we can't have a global variable for our state so we will write a createStore
function to create a hook on-the-fly every time we render a new layout.
Inside the function we will call create
with the the schema that includes cart
and setCart
. Then we'll give it the initial cart
value and supply a setCart
that will set the value with the cart:
const createStore = (cart: Cart) =>
create<{
cart: Cart;
setCart: (cart: Cart) => void;
}>((set) => ({
cart,
setCart(cart: Cart) {
set({ cart });
},
}));
One of the nice things about Zustand is it's pretty simple to implement a store!
Still inside of CartProvider.tsx
, we'll create the CartContext
.
This will be either the output of createStore
or null
as it's initialized:
const CartContext = createContext<ReturnType<typeof createStore> | null>(null);
Next, we'll create a custom hook called useCart
that will get the Zustand hook by calling useContext
passing in the CartContext
. The hook will throw an error if it doesn't find a context:
export const useCart = () => {
if (!CartContext)
throw new Error("useCart must be used within a CartProvider");
return useContext(CartContext)!;
};
Finally, we'll create the CartProvider
that will take the initial cart
and use the createStore
function to initialize some state that we'll then pass down to any children using the CartContext.Provider
:
const CartProvider = ({
cart,
children,
}: {
cart: Cart;
children: React.ReactNode;
}) => {
const [store] = useState(() => createStore(cart));
return <CartContext.Provider value={store}>{children}</CartContext.Provider>;
};
export default CartProvider;
Updating Components to use CartProvider
Similar to the process we followed before, we need to update our components to use the CartProvider
.
Over in layout.tsx
, we'll import the CartProvider
which will wrap our components:
import CartProvider from "./store/CartProvider";
Everything else in the layout will still work the same way! We're just changing the CartProvider
implementation.
Updating the Header
Inside of Header.tsx
, start by importing the useCart
hook from the CartProvider
. Then inside of the component, create a new cartHook
variable that is the output of useCart
. From there, we'll get the cart
by calling the the cartHook
with the selector:
// inside Header component
const cartHook = useCart();
const cart = cartHook((state) => state.cart);
As seen in the resources in the introduction, an alternative way to achieve the same result looks like this:
const cart = useCart()((state) => state.cart);
Updating the Cart Popup
Now over in CartPopup.tsx
we'll get the whole store including the cart
and setCart
:
// inside CartPopup.tsx
import { useCart } from "../store/CartProvider";
export default function CartPopup({
clearCartAction,
}: {
clearCartAction: () => Promise<Cart>;
}) {
const { cart, setCart } = useCart()();
...
Updating Add to Cart
Finally, over in AddToCart.tsx
we'll bring in the useCart
hook then use it to get setCart
:
// inside AddToCart.tsx
import { useCart } from "../store/CartProvider";
export default function AddToCart({
addToCartAction,
}: {
addToCartAction: () => Promise<Cart>;
}) {
const setCart = useCart()((state) => state.setCart);
...
Now with these changes, we can double check our work.
Over in the browser, we can see the initial cart data has the 2 items in it, and we can add items to the cart as expected.
With the cart context all set up, we can move on to setting up the reviews context.
Implementing Review Context
Similar to before, we'll create a new ReviewsProvider.tsx
file in the app/store
directory. Import the necessary functions for React context, as well as create
from Zustand. We'll also import the Review
type from @/api/types
.
We'll create a createStore
function that will give us back a Zustand hook with an array of reviews
, as well as a setReviews
function that will set the value with the reviews:
"use client";
import { useState, createContext, useContext } from "react";
import { create } from "zustand";
import { Review } from "@/api/types";
const createStore = (reviews: Review[]) =>
create<{
reviews: Review[];
setReviews: (Reviews: Review[]) => void;
}>((set) => ({
reviews,
setReviews(reviews: Review[]) {
set({ reviews });
},
}));
Next, we'll create our ReviewsContext
then create a custom hook called useReviews
that will give us the hook we can use to fetch reviews from the Zustand store. If it doesn't find a context, it will throw an error:
const ReviewsContext = createContext<ReturnType<typeof createStore>>(null!);
export const useReviews = () => {
if (!ReviewsContext)
throw new Error("useCart must be used within a CartProvider");
return useContext(ReviewsContext);
};
Finally, we'll create the ReviewsProvider
. Like before, we will use the useState
hook to hold the state of the store:
const ReviewsProvider = ({
reviews,
children,
}: {
reviews: Review[];
children: React.ReactNode;
}) => {
const [store] = useState(() => createStore(reviews));
return (
<ReviewsContext.Provider value={store}>{children}</ReviewsContext.Provider>
);
};
export default ReviewsProvider;
With the reviews context created, we can update our components to use it.
Updating Components for the Reviews Context
At the top of pages.tsx
we can import the ReviewsProvider
, then wrap the entire tree with it:
import ReviewsProvider from "@/app/store/ReviewsProvider";
...
// inside the ProductDetail return
return (
<ReviewsProvider reviews={product.reviews}>
<div className="flex flex-wrap">
...
Over in AverageRating.tsx
, we'll import the useReviews
hook and use it to fetch the reviews
state instead of using the reviews
prop:
import { useReviews } from "@/app/store/ReviewsProvider";
export default function AverageRating() {
const reviews = useReviews()((state) => state.reviews);
...
Remember, when we use the Zustand hook we call it then give it a function that will take the state and return the value we want.
We need to follow a similar process in Reviews.tsx
. Import the useReviews
hook, and remove the reviews
prop from the AverageRating
component:
// inside Reviews.tsx
const { reviews, setReviews } = useReviews()();
Now we can double check our work!
Checking Our Work
Back in the browser, we can see the reviews are being fetched from the server and displayed as expected in both the UI and the rendered source. We can also add reviews and see them appear in the list.
Now that you've seen how simple Zustand is, you can see why it's a popular choice for state management in React apps.
Next up, we'll re-implement this functionality with the Jotai library, which follows the atomic model for state management.