DEV Community

Cover image for Understanding Zustand
Chinwuba
Chinwuba

Posted on

Understanding Zustand

For a long time, state management felt more complicated than it needed to be.

I understood React's useState.

I understood passing props.

I understood lifting state up.

But the moment applications became larger, everything started getting messy.

Then I started learning Zustand.

At first, I was confused.

Not because Zustand was complicated.

So I decided to stop memorizing syntax and instead understand the problem Zustand was trying to solve.

This article is the explanation I wish I had when I first started learning it.

The Problem: React State Doesn't Scale Forever

Let's start with plain React.

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Simple.

Beautiful.

No problems.

But now imagine you're building a real application.

You have:

A Navbar
A Dashboard
A Profile page
Protected routes
Notifications

All of them need access to the logged-in user.

Where do you store the user?

You might start with:

const [user, setUser] = useState(null);

Then pass it down as props.

Then:

Then:

Then:

Suddenly you're passing the same piece of data through multiple components.

Some components don't even use it.

They're just forwarding it.

This is called prop drilling.

As applications grow, prop drilling becomes frustrating.

What Zustand Actually Solves

Zustand gives your application a shared state outside React components.

Think of it like this:

Without Zustand:

Component A

Component B

Component C

Component D

Every level passes props.

With Zustand:

   Store
  /  |  \
 /   |   \
Enter fullscreen mode Exit fullscreen mode

Navbar Dashboard Profile

Every component talks directly to the store.

No middleman.

No prop drilling.

The Mental Model That Made Zustand Click

Forget the library for a moment.

A Zustand store is just:

{
state,
actions
}

State = Data

Actions = Functions that modify the data

That's it.

Example:

{
count: 0,

increment() {
count++;
}
}

Zustand simply gives React a way to access and update that object.

Creating Your First Store

Install Zustand:

npm install zustand

Create a store:

import { create } from "zustand";

const useCounterStore = create((set) => ({
  count: 0,

  increment: () =>
    set((state) => ({
      count: state.count + 1,
    })),
}));
Enter fullscreen mode Exit fullscreen mode


plaintext
Notice two things:

State
count: 0
Action
increment: () => ...
Enter fullscreen mode Exit fullscreen mode


plaintext
That's the entire pattern.

What Is set()?

This was one of my biggest questions.

Think of set() as Zustand's version of React's state updater.

React:

setCount(count + 1);

Zustand:

set({
  count: 1,
});

Or:

set((state) => ({
  count: state.count + 1,
}));
Enter fullscreen mode Exit fullscreen mode


javascript
Whenever you want to update state, use set().

Using a Store Inside Components

function Counter() {
  const count = useCounterStore(
    (state) => state.count
  );

  const increment = useCounterStore(
    (state) => state.increment
  );

  return (
    <button onClick={increment}>
      {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode


javascript
Here we're selecting specific pieces of state.

This is important because Zustand only re-renders components that depend on changed values.

That's one reason it's so fast.

Building a Real Authentication Store

Let's create something closer to production.

We need:

User
Token
Notifications
Login
Logout
Initial State
user: null,
token: null,
notifications: [],

But we have a problem.

Refreshing the page resets state.

If the user refreshes:

user = null
token = null

They're logged out.

Not ideal.

Introducing localStorage

The browser provides:

localStorage

Data stored there survives page refreshes.

Save:

localStorage.setItem("token", token);

Read:

localStorage.getItem("token");

Remove:

localStorage.removeItem("token");

Why We Use JSON.stringify()

localStorage only stores strings.

This works:

localStorage.setItem(
  "token",
  "abc123"
);
Enter fullscreen mode Exit fullscreen mode


javascript

This doesn't:

localStorage.setItem(
  "user",
  { name: "Jeffrey" }
);
Enter fullscreen mode Exit fullscreen mode


javascript
Objects must become strings.

JSON.stringify(user);

When reading:

JSON.parse(
  localStorage.getItem("user")
);
Enter fullscreen mode Exit fullscreen mode


plaintext

Complete Auth Store

import { create } from "zustand";

export const useAuthStore = create(
  (set) => ({
    user:
      JSON.parse(
        localStorage.getItem("user")
      ) || null,

    token:
      localStorage.getItem("token") ||
      null,

    notifications: [],

    login: (user, token) => {
      localStorage.setItem(
        "user",
        JSON.stringify(user)
      );

      localStorage.setItem(
        "token",
        token
      );

      set({ user, token });
    },

    logout: () => {
      localStorage.removeItem("user");

      localStorage.removeItem("token");

      set({
        user: null,
        token: null,
      });
    },

    setNotifications: (
      notifications
    ) => {
      set({ notifications });
    },
  })
);
Enter fullscreen mode Exit fullscreen mode


javascript

Why Notifications Might Belong in Global State

This was another question I had.

Why not just keep notifications in component state?

Because notifications are usually displayed globally.

Example:

Navbar bell icon
Dashboard
Activity page
Real-time updates

Multiple unrelated components need the same data.

That's a strong sign the data belongs in a global store.

Why Not Use Cookies Instead?

Many wise developers(like me ofc) ask this.

Cookies and localStorage solve different problems.

Cookies:

Sent automatically with every request
Can expire automatically
Good for authentication when using HTTP-only cookies

localStorage:

Easy JavaScript access
Simpler API
Great for client-side persistence

Many production systems actually use:

Access Token → Memory
Refresh Token → HTTP-only Cookie

But for learning projects and many React applications, localStorage is easier to understand.

Zustand vs Redux

A quick comparison.

Redux:

dispatch({
type: "LOGIN",
payload: user,
});

Reducer:

switch(action.type) {
case "LOGIN":
}

Zustand:

login(user);

That's it.

Less boilerplate.

Less configuration.

Less mental overhead.

For many projects, that's enough.

Common Beginner Mistakes

Forgetting to Return an Object

Wrong:

create((set) => {
  count: 0;
});
Enter fullscreen mode Exit fullscreen mode


javascript
Correct:

create((set) => ({
  count: 0,
}));
Enter fullscreen mode Exit fullscreen mode


javascript

Using Semicolons Instead of Commas

Wrong:

{
  count: 0;
  user: null;
}
Enter fullscreen mode Exit fullscreen mode


javascript
Correct:

{
  count: 0,
  user: null,
}
Enter fullscreen mode Exit fullscreen mode


javascript

Parsing Non-JSON Data

Wrong:

JSON.parse(
  localStorage.getItem("token")
);
Enter fullscreen mode Exit fullscreen mode

If the token is already a string:

localStorage.getItem("token");

is enough.

Final Thoughts

The biggest lesson I learned wasn't a Zustand API.

It was this:

State management libraries aren't really about managing state. They're about managing complexity.

When your application grows beyond a handful of components, sharing data becomes harder than creating it.

Zustand solves that problem with an incredibly small API:

State
Actions
set()

That's essentially the whole library.

Once I stopped focusing on the syntax and started focusing on the problem, Zustand finally clicked.

And honestly, that's true for most technologies we learn as developers.

We don't learn tools by memorizing APIs.

We learn them by understanding why they exist.
Once again, write code as art.

Top comments (0)