DEV Community

Cover image for Simple & Elegant State-Management with Zustand
Halil Durak
Halil Durak

Posted on

Simple & Elegant State-Management with Zustand

Have you ever wanted to learn state-management but it felt so overwhelming to get started with Redux? Or maybe you're already using Redux but don't want to take the burden anymore? If so, you might want to take a shot at Zustand! Its an amazing state management library that's easy to get started and maintain in a long run. In this post, we'll dive into creating & sharing stores with this tiny Redux alternative.

Why Zustand?

  • Small & Performant
  • Beginner friendly
  • Rapidly adopted by Frontend devs

In this tutorial, we'll see usage of Zustand directly on React (via Vite). I'll likely add another chapter for using it with Next.js too.

Setup

Fire up your favorite code editor and let's get started! We'll first need to create a fresh project and install the library. I'll be using Node.js as runtime and Bun as package manager throughout the tutorial. You can use whatever suits to you!

$ bun create vite
✔ Project name: … zustand-episode-1
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC

Scaffolding project in zustand-episode-1...

Done. Now run:

  cd zustand-episode-1
  bun install
  bun run dev
Enter fullscreen mode Exit fullscreen mode

After you've installed the dependencies, install the Zustand as the following:

bun add zustand
Enter fullscreen mode Exit fullscreen mode

Cool! I've also removed everything inside App.css, index.css and App component:

function App() {
  return <></>;
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Creating a Store

What we need first is a store. Store is where we put our states and methods. Its possible to keep primitives, objects and functions in them. Import create from Zustand and create your own:

import { create } from "zustand";

const useCountStore = create(() => ({ count: 0 }));

function App() {
  const count = useCountStore((state) => state.count);

  return <>{count}</>;
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Notice that create takes a callback that returns an object (It is () => ({}) not () => {}). The object we return is our store. create function also gives back a hook that we can use to access our store. Inside the App component, we have used the hook to retrieve the count.

That's cool but surely we'd like to mutate the count.

Store Actions

The callback we've passed to create can optionally take set argument which is a function that we can use to mutate our state. Modify the useCountStore hook as the following:

const useCountStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Enter fullscreen mode Exit fullscreen mode

Let's run it down;

  1. increment is a function that calls set,
  2. set takes state as an argument which is the current state (similar to set function we get from useState) and returns a new object where the it takes state.count and increments by 1,
  3. same applies to decrement but instead it decrements the count by 1.

Now that we've created our actions, let's use them in our component to mutate the count. We can access to our actions like this:

function App() {
  const count = useCountStore((state) => state.count);
  const increment = useCountStore((state) => state.increment);
  const decrement = useCountStore((state) => state.decrement);

  ...
Enter fullscreen mode Exit fullscreen mode

Or, we can take advantage of destructive assignment:

function App() {
  const { count, increment, decrement } = useCountStore();

  ...
Enter fullscreen mode Exit fullscreen mode

Much cleaner! I've also created a barebones interface to play around:

function App() {
  const { count, increment, decrement } = useCountStore();

  return (
    <>
      {/* Count value */}
      <span>{count}</span>
      <br />
      {/* Button that increments the count value */}
      <button type="button" onClick={increment}>
        increase
      </button>
      {/* Button that decrements the count value */}
      <button type="button" onClick={decrement}>
        decrease
      </button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

You should be able to see the current count value and increment/decrement it.

Accesing the Store from Another Component

If we're persisting a store outside of our component, we most likely want to access it from some other component. This is also one of the great things about using a state manager, it avoids prop drilling.

Right now our store lives inside App.tsx. Let's start by moving it to somewhere else. How about a stores folder inside src? That sounds like a good idea. Or, we could approach it just like an hook and move it to hooks folder. I'll go with the former but like I said earlier, whatever suits. My folder structure looks like this now:

node_modules/
public/
src/
  stores/
    count-store.ts
  App.tsx
  main.tsx
  ...
Enter fullscreen mode Exit fullscreen mode

In stores/count-store.ts, I've took the code for our store and pasted it there. Then I've added export keyword right before useCountStore declaration.

stores/count-store.ts

import { create } from "zustand";

export const useCountStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Enter fullscreen mode Exit fullscreen mode

While at it, we can also add some typing to make our code more understandable:

stores/count-store.ts

import { create } from "zustand";

// States are typed here
interface CountState {
  count: number;
}

// Actions (or functions) are typed here
interface CountActions {
  increment: () => void;
  decrement: () => void;
}

// We create a unified type from them
type CountStore = CountState & CountActions;

// Indicate the type here
export const useCountStore = create<CountStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Enter fullscreen mode Exit fullscreen mode

This is self-explanatory. If you didn't know, & in TypeScript merges the types.

Let's also create a new component in src named Counter.tsx and fill it in with the following:

src/Counter.tsx

import { useCountStore } from "./stores/count-store";

export default function Counter() {
  const count = useCountStore((state) => state.count);

  return <span>{count}</span>;
}
Enter fullscreen mode Exit fullscreen mode

Sole purpose of this component is to show count value, I'm aware that we're already doing it in App but the idea here is to show that we can access the same store from some other component.

In App.tsx, import our Counter component and render it alongside our buttons:

import Counter from "./Counter";
import { useCountStore } from "./stores/count-store";

function App() {
  const { increment, decrement } = useCountStore();

  return (
    <>
      {/* Count value */}
      <Counter />
      <br />
      {/* Button that increments the count value */}
      <button type="button" onClick={increment}>
        increase
      </button>
      {/* Button that decrements the count value */}
      <button type="button" onClick={decrement}>
        decrease
      </button>
    </>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Notice that we're only mutating the count in App.tsx. The rendering of count is moved to Counter.tsx and it still works perfectly! If our tree would be deeply nested, it still won't be a problem for us to access our store and read/mutate the state.

That's it for part 1. I hope you got the hang of Zustand and liked it! In the follow-up, we'll be trying to integrate React Context API to this and I'll also show you how you can use Zustand in your Next.js apps. Take care!

Top comments (0)