How to implement zustand in your Next.js app.

How to implement zustand in your Next.js app.

The power of zustand: State management at its finest.

Introduction

I have always been overwhelmed by the boilerplate of most state management libraries, like the redux toolkit. This article introduces how to use this simple yet powerful package that reduces boilerplate code and manages the state in your Next.js application. I will also help you overcome the nasty hydration errors we get when using zustand state values in Next.js.

Prerequisites

To get a good read of this article, you should understand the basics of Next.js and basic state management concepts.

Introduction to zustand

What is zustand?

Zustand is a state management package that is small, scalable, and easy to use. It abstracts a lot of boilerplate code for you, making it easier to work with. Zustand is my go-to package for most projects.

Why is zustand such a good choice?

  • Less boilerplate: Zustand design natively abstracts the complex boilerplate code you do when working with most state management packages, e.g., Redux. It provides a much cleaner API for managing the state of your applications and also reduces the amount of time and code needed to set up the state of your application.

  • Familiar API: Zustand API pattern would be familiar to developers with experience in react state management. The API uses hooks.

  • Ease of Learning: Zustand is easy to learn, especially if you're already comfortable with React.

  • Scalability: Zustand can be used in small projects and large-scale applications. Its flexibility makes it a good choice for various sizes and complex applications.

  • Small Bundle Size: Zustand is a lightweight library with a small bundle size.

Setting up the Next.js counter app.

We would build a simple counter app in Next.js to demonstrate the capabilities of zustand.

Creating a new Next.js app.

We use create-next-app to create our counter app in the terminal, then follow the instructions.

Make sure you are in the correct directory.

We would be using the app directory from the latest version of Next.js. Now, let’s get to the boilerplate and install other required dependencies.

Boilerplate and installation of required dependencies.

We would start by stripping off the boilerplate code that comes with Next.js and creating a boilerplate or template for our counter app.

Here’s how the home page looks after stripping off the original boilerplate code.

export default function Home() {
  return <main className=""></main>;
}

We will be using Tailwind CSS to style the application:

"use client";
export default function Home() {
  return (
    <main className="w-full h-screen bg-slate-800 text-white">
      <h1 className="text-center text-4xl">Simple Counter</h1>
      <span className="text-center mt-3 block">Result: {0} </span>
      <div className="flex items-center justify-center gap-4 mt-8">
        <button
          className="w-max h-max p-2 bg-green-400 rounded-md font-mono
         hover:bg-green-600 transition-colors duration-300"

        >
          Add(10)
        </button>
        <button
          className="w-max h-max p-2 bg-green-400 rounded-md font-mono
         hover:bg-green-600 transition-colors duration-300"
        >
          Remove(10)
        </button>
      </div>
    </main>
  );
}

Here, we added a header and two buttons; the first button will add ten to the counter, the second removes ten, and we display the live results in the span tag just below the header.

Here’s how it looks in the browser:

Let’s get to installing zustand. Navigate to the terminal, ensure you are in the correct directory, then install the package with the following command.

That was easy. Now, let’s integrate state management.

Implementing state management in zustand.

To integrate zustand, we need to create a central store in the Next.js app directory; this store is where all our zustand logic goes, and what’s better is that zustand allows us to use our store as a hook!

Zustand uses the pattern of a central point of truth; the entire application state gets stored in a single centralized store.

We create a store folder and a file called Zustand.js; this name can be anything you want. Now, we construct our store in this file by doing this:

import { create } from "zustand";

export const useCountStore = create((set) => ({
  count: 0,
  increaseCount: () => set((state) => ({ count: state.count + 10 })),
  decreaseCount: () => set((state) => ({ count: state.count - 10 })),
}));

Here we are doing a few things:

  • We import the create function from zustand. This function accepts a callback function as an argument; this callback has a value set used to merge and manipulate state.

  • We define the state and actions in the callback and return them as objects.

  • We export the custom hook returned by the create function; this makes it usable anywhere in our application.

The state can be of any value in zustand, objects, null, functions, etc.

We defined three states in the callback: an object called count, and increaseCount, decreaseCount, which are both functions. Within these functions, we use the set function to manipulate the application's state. The set function takes an object named state as its parameter. This object holds the store's current state, and we use it to access values from the store in our actions or functions.

Zustand promotes immutability by default, ensuring that state updates get handled in an immutable fashion.

Now, to access this hook in our app, we import it like this:


"use client";
import { useCountStore } from "./store/zustand";

export default function Home() {
  const increaseCount = useCountStore((state) => state.increaseCount);
  const decreaseCount = useCountStore((state) => state.decreaseCount);
  const count = useCountStore((state) => state.count);

  return (
    <main className="w-full h-screen bg-slate-800 text-white">
      <h1 className="text-center text-4xl">Simple Counter</h1>
      <span className="text-center mt-3 block">Result: {count}</span>
      <div className="flex items-center justify-center gap-4 mt-8">
        <button
          className="w-max h-max p-2 bg-green-400 rounded-md font-mono
         hover:bg-green-600 transition-colors duration-300"
          onClick={increaseCount}
        >
          Add(10)
        </button>
        <button
          className="w-max h-max p-2 bg-green-400 rounded-md font-mono
         hover:bg-green-600 transition-colors duration-300"
          onClick={decreaseCount}
        >
          Remove(10)
        </button>
      </div>
    </main>
  );
}

Here’s what we are doing:

  • We access the state by calling the useCountStore hook, which takes in a callback function that has the value of the state, and then we return the exact value needed from the state object.

  • We extracted the two functions, plus the count object, and stored them in variables used in our template.

  • We attach a click event to each button. When the button gets clicked, the respective functions attached to it (increaseCount and decreaseCount) get fired.

  • The result is displayed using the count object in the span tag. Now we have a working counter using zustand, with no context providers or boilerplate.

State management at its finest!

Persisting data in local storage in zustand.

Zustand makes persisting our state data in local storage easier with the persist middleware.

Note: We can also implement asynchronous storage. The default is local storage, which is the focus of this course.

We start by importing the middleware into our zustand store.

import {persist } from "zustand/middleware";

Then we modify our hook like this:

import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useCountStore = create(
  persist(
    (set) => ({
      count: 0,
      increaseCount: () => set((state) => ({ count: state.count + 10 })),
      decreaseCount: () => set((state) => ({ count: state.count - 10 })),
    }),
    {
      name: "counterstore", // name of the item in the storage (must be unique)
    }
  )
);

In the create function, we call the persist middleware, which takes a callback function with the state logic (state object and actions) and an object with the name you intend to store the item in the storage with. This name must be unique.

Now, our state gets stored in the local storage, and even if the page gets reloaded, previous values still get retained in the memory. However, using the state values that change frequently directly in your app causes an error in Next.js due to the hydration process.

Fixing hydration errors from zustand when using Next.js.

We get this error when we reload the page:

Hydration is a process that allows React to take over the HTML rendered on the server and attach interactivity to it on the client side. Here is how Next.js uses hydration:

  1. Server-Side Rendering (SSR):

    • When a user requests a page in a Next.js application, the server generates the HTML for that page alongside the initial data required for rendering.

    • This initial HTML is sent to the client's browser as part of the response.

    • At this point, the HTML is static and doesn't have interactivity. It's just plain HTML with placeholders for React components.

    • The client-side JavaScript bundle, which includes React and the application code, is also sent to the client.

    • When the client receives the HTML and JavaScript bundle, react is loaded and initialized.

    • React then hydrates the HTML, meaning it attaches event listeners and sets up the component tree to match the structure generated on the server.

    • Event listeners, such as click handlers and form submissions, are bound to the HTML elements, making the page interactive.

    • Data fetched on the server is also used to populate the initial state of the components, ensuring that the page appears with the correct data immediately.

  2. Client-Side Rendering (CSR):

    • For pages or components that are loaded dynamically or require client-side routing, Next.js uses CSR.

    • In CSR, the initial HTML is minimal and doesn't contain complete page content. Instead, it includes the necessary JavaScript code and placeholders for where the content gets rendered.

    • When the JavaScript bundle is loaded and executed on the client, it fetches the necessary data and renders the content, replacing the placeholders.

    • The same hydration process occurs in CSR, where React attaches event listeners and makes the page interactive.

Now, back to our error: the error is due to the difference in content before and after react hydration. We are getting data from local storage; this involves some JavaScript, which means the page has to be hydrated before data is retrieved.

Next.js compares the rendered component on the server with the one rendered on the client after hydration. However, since you are getting data from the browser (local storage) to alter your component, the two renders will be different, and we get an error.

Note: The error occurs because the content generated on the server and the content generated on the client must match precisely for hydration to work. If there are differences, react will complain about the mismatch.

The errors usually range from the following:

  • Text content does not match server-rendered HTML.

  • Hydration failed because the initial UI did not match the render on the server.

  • There was an error while hydrating; because this error happened outside of a suspense boundary, the entire root will switch to client rendering.

The recommended way to overcome this is by creating a custom hook that delays a state change in your component.

We create a new file in our store folder called useStore, where we create a custom hook.

//useStore.js
import { useState, useEffect } from "react";

const useStore = (store, callback) => {
  const result = store(callback);
  const [data, setData] = useState();

  useEffect(() => {
    setData(result);
  }, [result]);

  return data;
};

export default useStore;

This hook allows you to access and subscribe to the state from the store and automatically updates your component when the state changes.

How does it work:

  • This hook takes in our actual state management store and a callback function that is usually passed to our store to select the pieces of state we want to work with.

  • A piece of local state called data is initialized using the useState hook. This data state will hold the current state value returned by the store(callback) function.

  • The useEffect hook keeps the data state in sync with the store state. It has a dependency array with the result state, which means the effect will be triggered whenever the result (the value returned by the store(callback)) changes.

  • Finally, the data state gets returned from the useStore custom hook; this allows the component using useStore to access the state value it needs.

Now, let's use this hook in our application. Our existing store requires no further changes.

"use client";
import { useCountStore } from "./store/zustand";
import useStore from "./store/useStore";

export default function Home() {
  const increaseCount = useCountStore((state) => state.increaseCount);
  const decreaseCount = useCountStore((state) => state.decreaseCount);
//custom hook use case
  const count = useStore(useCountStore, (state) => state.count);

  return (
    <main className="w-full h-screen bg-slate-800 text-white">
      <h1 className="text-center text-4xl">Simple Counter</h1>
      <span className="text-center mt-3 block">Result: {count}</span>
      <div className="flex items-center justify-center gap-4 mt-8">
        <button
          className="w-max h-max p-2 bg-green-400 rounded-md font-mono
         hover:bg-green-600 transition-colors duration-300"
          onClick={increaseCount}
        >
          Add(10)
        </button>
        <button
          className="w-max h-max p-2 bg-green-400 rounded-md font-mono
         hover:bg-green-600 transition-colors duration-300"
          onClick={decreaseCount}
        >
          Remove(10)
        </button>
      </div>
    </main>
  );
}

When accessing the count state; we pass our count store into the hook and a callback function.

Note: We only do this when accessing data values from the state, not functions!

This solution solves the hydration errors, and we get a working counter app with local storage!

Test it here:

Conclusion

That’s all, folks. I hope you learned a few. If you followed through, you should now be able to use zustand in your Next.js app, persist data in local storage, and fix hydration errors.

This article covers the barebones of the zustand package. There’s still a lot to learn. More to come from this blog!

You can read more in the additional resources section below. Thank you.

Additional details

Usage in next.js

Persisting store data

introduction to zustand