ProNextJS
    Loading
    solution

    Implement Zustand for State Management

    Jack HerringtonJack Herrington

    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.

    Transcript

    Clearly, the first thing we need to do is add zustand to our project. Now, normally with zustand, you create a global hook. It's pretty easy. You call the create function that you get from zustand, you give it the schema that you want for your store, and it gives you back a global hook that you can use

    anywhere and it's just by its nature global. But we know we can't have global variables. So what we're going to do to manage that in the App Writer context is we are going to use React context to teleport the hook wherever we want it. So let's go and create a cart provider that

    provides a zustand hook to anyone that needs the cart. So within app, I'm going to create a new file called store cart provider. Now, that's going to get pretty easily confused with the cart provider we already have in cart context. So let's just get rid of that. So it's going to be a client component. We're going to use context.

    So we're going to create context as well as use context. We're going to use state to store the hook that we will then use in that context. Next, we're going to create from zustand. That's going to allow us to create that hook that we're going to pass around through that context and then we'll bring in the type for the cart. So the next thing we're going to do is create

    the store hook creator function. Now, just like we did the Redux, you can't have a global variable. So we're going to create a function that allows us to create a hook on the fly in the layout every time we render a new layout. That is this create store function. It calls create with the schema. So that's going to have the cart as well as set cart.

    Then we give it the cart value initially and we have a set cart that simply just sets the value with the cart. You can see why people like zustand. It is very, very simple when it comes to the actual implementation of zustand. It's a little more complex because we're doing this hook within a context thing

    because we're in the context of the app writer and we can't have any globals. Otherwise, it's really nice in terms of an API service. Next, we'll create our cart context. That's just going to have the output of the create store or null as it's initialized. Then we'll create our custom hook.

    This custom hook is going to get you the zustand hook. So use cart will give you the zustand hook and then from there, you can get the cart or get set cart or both if you want. Then finally, we create our cart provider that is going to provide our store down the line and we're going to use

    use state to hold the create store that we just created. So let's go over in our layout and bring that in. So now we'll get cart provider from our cart provider. Otherwise, it's exactly the same thing. It takes a cart just like we did before. So let's go and make our changes to our components. We'll go into components, we'll start with a header again.

    We'll get the use cart from the cart provider. Then just to show you how this is going to go the first time. So what we're going to get is a cart hook from use cart. Then with that cart hook,

    we're going to get the state and we're going to return just the cart. That's one of the nice things that zustand allows you to do. The hook that you get back from zustand, you basically just give it a selector and that's how you get just the values that you want off of the state.

    Now, we can make this a lot simpler by just simply doing use cart here. It looks a little odd. You're basically just calling a function that you would then in turn call, but it works. Let's go to our cart pop-up. Again, we'll get the use cart from the right spot.

    This time, we'll get the whole store including the cart and the set cart. Then finally, over an add to cart, we'll go get use cart from our right spot. Then we'll just get the set cart and let's see, let's try it out. All right, looks pretty good. Seems to have the initial cart. Let's add the Castle t-shirt to the cart.

    Pretty nice. All right, cool. So that's the cart implementation in zustand. It was easy enough. Let's go just do reviews right now. So to do reviews, we're going to go back into our store and create a reviews provider. We'll bring in everything we need and then we'll create a create store function that's going to give us back

    a zustand hook that has the array of reviews, as well as set reviews to go and set those reviews. Next, we'll create our reviews context. We'll create our use reviews custom hook. Again, that's going to give us that zustand hook. It's not actually going to give us the reviews, it's just going to give us that hook that we then

    call to get either the reviews or set reviews or both. Then finally, we create our provider. Again, we're going to use usestate to hold the state of the store. Now, let's go and implement this by going and bring it into our products. We'll bring in our reviews provider,

    put it at the top of our tree, and there we go, looking pretty good. Now, let's go and actually implement it over in average writing. So bring in our use reviews. Now, we don't actually need to bring this in as a prop anymore. That's nice. Then we'll call that use reviews hook. That'll give us back the zustand hook.

    With the zustand hook, we will then give it a function that will take the state and return the reviews. Of course, this guy is now complaining at us that we're giving it reviews and we don't need to. Let's get rid of those and save. But now we need to port reviews. Let's go take a look. We don't need the reviews,

    but we do need user reviews, and we'll get reviews and set reviews from that. Now, we're not using set reviews yet, except we do by just adding it on here. Let's give it a try. Refresh looks pretty good. Let's add another cool Castle shirt.

    Again, with the one, submit review. Looks great. Let's go make sure that it is in the SSR at output. There we go. Another cool Castle shirt in our server-side rendered output. That means that we are initializing

    our zustand store properly and at the right time so that it's ready for server-side rendering. As you can see, why zustand is so popular because it is just this simple. Next up, we will re-implement this in another model entirely, and that's the atomic model using

    another Daichi Kato library. This one is called Jotai. Thank you so much, Daichi, for actually giving me a state manager that is a lot easier to pronounce.