Zustand A Simple and Powerful State Management Solution for Reac

State management is one of those topics every React developer eventually encounters.

At first, useState and useContext are enough. But as an application grows, you may find yourself passing state through multiple components, creating complex Context providers, or writing a lot of boilerplate just to share a few values.

This is where Zustand comes in.

Zustand is a small, simple state-management library for React that lets you create global stores with minimal boilerplate.

In this article, we'll understand:

  • What Zustand is
  • Why you might need it
  • How Zustand works
  • Creating your first store
  • Reading and updating state
  • Async actions
  • Selectors
  • Avoiding unnecessary re-renders
  • Zustand vs Context API
  • Zustand vs Redux
  • A practical project example
  • Best practices

What Is Zustand?

Zustand is a lightweight state-management library for JavaScript and React.

Instead of putting shared state inside React Context providers, you create a store that can be accessed directly from your components.

A basic Zustand store looks like this:

import { create } from "zustand";

type CounterStore = {
  count: number;
  increment: () => void;
  decrement: () => void;
};

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

  increment: () =>
    set((state) => ({
      count: state.count + 1,
    })),

  decrement: () =>
    set((state) => ({
      count: state.count - 1,
    })),
}));

Now any component can access this state.

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

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

That's essentially the core idea behind Zustand.


Why Do We Need State Management?

Imagine an application with the following structure:

App
 ├── Header
 │    └── UserProfile
 │
 ├── Sidebar
 │    └── UserBalance
 │
 └── Dashboard
      └── Transactions

Suppose the user's balance needs to be displayed in both the header and dashboard.

With local state, you might end up passing the balance through multiple components.

App
 ↓
Header
 ↓
UserProfile

This is commonly known as prop drilling.

With Zustand, the components can access the same store directly.

             Zustand Store
            /      |      \
           ↓       ↓       ↓
       Header   Sidebar  Dashboard

This makes shared state easier to organize.


Installing Zustand

Install Zustand using npm:

npm install zustand

Or with Yarn:

yarn add zustand

After installation, you can create your first store.


Creating a Zustand Store

Let's create a simple user store.

import { create } from "zustand";

type UserStore = {
  name: string;
  email: string;
  setUser: (name: string, email: string) => void;
};

export const useUserStore = create<UserStore>((set) => ({
  name: "",
  email: "",

  setUser: (name, email) =>
    set({
      name,
      email,
    }),
}));

The store contains:

State
 ├── name
 └── email

Actions
 └── setUser()

The set function is provided by Zustand and is used to update the store.


Reading State

Inside a component:

const name = useUserStore((state) => state.name);

You can also access multiple values:

const name = useUserStore((state) => state.name);
const email = useUserStore((state) => state.email);

Then:

function Profile() {
  const name = useUserStore((state) => state.name);
  const email = useUserStore((state) => state.email);

  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
    </div>
  );
}

Notice something important:

There is no Provider.

You don't need:

<UserProvider>
  <App />
</UserProvider>

The store can be consumed directly.


Updating State

Let's create a counter.

import { create } from "zustand";

type CounterStore = {
  count: number;
  increment: () => void;
};

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

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

Use it like this:

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

  return (
    <>
      <h1>{count}</h1>

      <button onClick={increment}>
        Increment
      </button>
    </>
  );
}

When increment() executes, Zustand updates the store and subscribed components receive the new state.


The set Function

One of the most important concepts in Zustand is set.

You can update state directly:

set({
  name: "Vijay",
});

Or derive the new state from the previous state:

set((state) => ({
  count: state.count + 1,
}));

For values that depend on the previous state, the second approach is generally preferable.

For example:

set((state) => ({
  coins: state.coins - 10,
}));

This is particularly useful for applications such as wallets, counters, carts, and real-time usage tracking.


Actions Belong in the Store

Instead of putting business logic inside components, you can keep it inside the store.

For example:

type WalletStore = {
  coins: number;
  addCoins: (amount: number) => void;
  spendCoins: (amount: number) => void;
};

export const useWalletStore = create<WalletStore>((set) => ({
  coins: 1000,

  addCoins: (amount) =>
    set((state) => ({
      coins: state.coins + amount,
    })),

  spendCoins: (amount) =>
    set((state) => ({
      coins: state.coins - amount,
    })),
}));

Your component becomes simple:

const spendCoins = useWalletStore(
  (state) => state.spendCoins
);

spendCoins(10);

The component doesn't need to know how the wallet state is modified.


Async Actions

Zustand also works nicely with asynchronous operations.

For example, fetching a user:

type UserStore = {
  user: User | null;
  loading: boolean;
  fetchUser: () => Promise<void>;
};

export const useUserStore = create<UserStore>((set) => ({
  user: null,
  loading: false,

  fetchUser: async () => {
    set({ loading: true });

    const response = await fetch("/api/user");
    const user = await response.json();

    set({
      user,
      loading: false,
    });
  },
}));

Then:

function Profile() {
  const user = useUserStore((state) => state.user);
  const loading = useUserStore((state) => state.loading);
  const fetchUser = useUserStore((state) => state.fetchUser);

  // ...
}

However, it's important to distinguish between server state and client state.

Libraries such as TanStack Query are often better suited for server-state concerns like:

  • API caching
  • Request deduplication
  • Refetching
  • Pagination
  • Background updates

Zustand is often a better fit for client-side application state.


Selectors: An Important Zustand Concept

Consider this:

const store = useUserStore();

You are subscribing to the entire store.

A better approach is usually to select only what the component needs:

const name = useUserStore((state) => state.name);

Now the component is specifically interested in name.

You can think of it like:

Zustand Store
│
├── name       ← Component subscribes
├── email
├── settings
├── notifications
└── preferences

The selector tells Zustand:

"I only care about this part of the store."

This can help prevent unnecessary component updates.


Selecting Multiple Values

You may need multiple values:

const name = useUserStore((state) => state.name);
const email = useUserStore((state) => state.email);

Or select an object:

const user = useUserStore((state) => ({
  name: state.name,
  email: state.email,
}));

When selecting objects or arrays, be mindful of reference equality and use the appropriate Zustand selector/equality mechanisms when necessary.


Zustand Store Architecture

For a larger application, don't put everything into one massive store.

Instead, separate state by responsibility.

src/
│
├── stores/
│   ├── authStore.ts
│   ├── userStore.ts
│   ├── walletStore.ts
│   ├── callStore.ts
│   └── settingsStore.ts
│
├── components/
├── screens/
└── services/

For example:

// authStore.ts
export const useAuthStore = create(...);

// walletStore.ts
export const useWalletStore = create(...);

// settingsStore.ts
export const useSettingsStore = create(...);

This makes the application easier to maintain.


A Real-World Example

Imagine we're building a calling application.

We might have state like:

Call Store
│
├── isCalling
├── callType
├── duration
├── remoteUser
├── startCall()
├── endCall()
└── updateDuration()

A store could look like:

type CallType = "audio" | "video" | null;

type CallStore = {
  isCalling: boolean;
  callType: CallType;
  duration: number;

  startCall: (type: CallType) => void;
  endCall: () => void;
};

export const useCallStore = create<CallStore>((set) => ({
  isCalling: false,
  callType: null,
  duration: 0,

  startCall: (type) =>
    set({
      isCalling: true,
      callType: type,
      duration: 0,
    }),

  endCall: () =>
    set({
      isCalling: false,
      callType: null,
      duration: 0,
    }),
}));

Then the call screen can simply subscribe to the state it needs:

function CallScreen() {
  const callType = useCallStore(
    (state) => state.callType
  );

  const duration = useCallStore(
    (state) => state.duration
  );

  return (
    <div>
      <p>{callType}</p>
      <p>{duration}s</p>
    </div>
  );
}

This keeps the UI layer focused on rendering while the store handles application state.


Zustand vs Context API

Zustand and React Context solve related but different problems.

FeatureContext APIZustand
Built into ReactYesNo
Global/shared stateYesYes
Provider requiredYesNo
BoilerplateLow–MediumLow
Selective subscriptionsMore manualBuilt in through selectors
DevtoolsLimitedAvailable through middleware
Async actionsManualStraightforward
Large application stateCan become cumbersomeConvenient

Context is still an excellent choice for relatively stable values such as:

  • Theme
  • Locale
  • Authentication context
  • Dependency/configuration values

Zustand becomes attractive when you have frequently changing shared client state.


Zustand vs Redux

Redux is a mature state-management ecosystem with strong conventions and extensive tooling.

Zustand takes a much smaller and more direct approach.

A Redux architecture often involves concepts such as:

Component
   ↓
Dispatch Action
   ↓
Reducer
   ↓
Store
   ↓
Selector
   ↓
Component

With Zustand:

Component
   ↓
Store Action
   ↓
State Update
   ↓
Component

Neither approach is universally correct.

The choice depends on your application's requirements, team conventions, existing architecture, and desired level of structure.


Middleware

Zustand provides middleware that can extend stores with additional capabilities.

For example, persistence:

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

export const useAuthStore = create(
  persist(
    (set) => ({
      token: null,

      setToken: (token: string) =>
        set({ token }),
    }),
    {
      name: "auth-storage",
    }
  )
);

This can persist selected client state between application sessions.

Other middleware and integrations can support use cases such as development tooling and state synchronization.


Important: Don't Put Everything in Zustand

One common mistake is treating Zustand as the place for all application data.

For example, suppose your application receives:

Users
Products
Orders
Comments
Notifications
Transactions

from an API.

You don't necessarily want to copy all of this server data into a Zustand store.

A useful mental model is:

Server State
     ↓
TanStack Query

Client State
     ↓
Zustand

For example:

// Server state
const { data } = useQuery({
  queryKey: ["users"],
  queryFn: fetchUsers,
});

And:

// Client state
const isModalOpen = useModalStore(
  (state) => state.isOpen
);

This separation can make your architecture much cleaner.


Best Practices

1. Keep stores focused

Instead of:

useAppStore

containing 100 different properties, consider smaller stores:

useAuthStore
useCartStore
useModalStore
useCallStore
useSettingsStore

2. Use selectors

Prefer:

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

instead of unnecessarily subscribing to the entire store.


3. Keep business logic in actions

Instead of:

setCoins(coins - 10);

spread across components, expose an action:

spendCoins(10);

This keeps business rules centralized.


4. Don't duplicate server state

If data is primarily coming from an API, consider a server-state library such as TanStack Query instead of manually maintaining duplicate copies in Zustand.


5. Keep components simple

A good component should ideally focus on:

UI
 ↓
User interaction
 ↓
Store action

rather than containing complex application-wide state logic.


When Should You Use Zustand?

Zustand is particularly useful when you have shared client-side state such as:

  • Shopping cart
  • Authentication UI state
  • Modals
  • Filters
  • Multi-step forms
  • Audio/video call state
  • Application preferences
  • UI settings
  • Temporary workflow state

For a small component with isolated state, useState may be all you need.

For server data, TanStack Query or another server-state solution may be more appropriate.

For highly structured state management with strict conventions and a large ecosystem, Redux may be a better fit.


Final Thoughts

Zustand is popular because it keeps state management simple.

You don't need to build a large architecture just to share state between components.

The basic pattern is:

Create Store
     ↓
Define State
     ↓
Define Actions
     ↓
Select State
     ↓
Update State

The biggest advantage isn't simply that Zustand requires less code.

It's that it gives you a straightforward way to separate application state from UI components without forcing a large amount of framework structure onto your application.

For many React applications, a combination such as:

React
   +
Zustand
   +
TanStack Query

provides a clean separation between UI state and server state.

And once you understand selectors, actions, persistence, and store organization, Zustand becomes a very practical tool for building scalable React applications.