Skip to content
C Codeloom
React

Zustand: Minimal State Management for React

Skip the reducer boilerplate. Zustand gives React apps a tiny global store with hooks, selectors, and middleware in fewer than 100 lines of API.

·5 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • How Zustand differs from Redux and Context
  • How to create a store and read it from components
  • Why selectors matter for re-render control
  • How to persist state and use middleware
  • When Zustand is the right pick

Prerequisites

Redux gave React global state. Context gave it a tree-scoped escape hatch. Both work, both have ceremony. Zustand sits in the middle: a tiny hook-based store with no provider, no reducers, no action types, and just enough structure to keep large apps sane.

The mental model

A Zustand store is a function. You call create once, pass it a setter-aware initializer, and get back a hook. Components call the hook with a selector to read slices of state. State changes happen by calling set inside store actions. There is no context provider in the tree, no dispatcher, no middleware required out of the box.

Install and create a store

npm install zustand
import { create } from 'zustand';

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

export const useCounter = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
  reset: () => set({ count: 0 }),
}));

That is the whole API surface for a basic store. State and actions live in the same object. Actions call set with either a partial object or an updater function.

Reading state in components

function CounterButton() {
  const count = useCounter((s) => s.count);
  const increment = useCounter((s) => s.increment);
  return <button onClick={increment}>Count is {count}</button>;
}

The selector pattern is the single most important habit to build. useCounter((s) => s.count) causes the component to re-render only when count changes. If you write const state = useCounter() instead, you re-render on every state change, which defeats the point.

Computed values and shallow comparison

Sometimes you need multiple values. Returning an object from a selector triggers re-renders on every change because object identity is new each call. Use useShallow for shallow comparison.

import { useShallow } from 'zustand/react/shallow';

function Header() {
  const { user, unread } = useStore(
    useShallow((s) => ({ user: s.user, unread: s.unread })),
  );
  return <span>{user.name} ({unread})</span>;
}

Async actions

Actions can be async. There is no special API.

type UserState = {
  user: User | null;
  loading: boolean;
  load: (id: number) => Promise<void>;
};

const useUser = create<UserState>((set) => ({
  user: null,
  loading: false,
  load: async (id) => {
    set({ loading: true });
    const res = await fetch(`/api/users/${id}`);
    const user = await res.json();
    set({ user, loading: false });
  },
}));

Note that async writes work because set can be called multiple times. Errors are your responsibility — wrap in try/catch and store an error field if you need one.

Slices for larger stores

A single store can grow. Split it into slice creators and compose.

import { create, StateCreator } from 'zustand';

type AuthSlice = {
  token: string | null;
  login: (t: string) => void;
};

type CartSlice = {
  items: string[];
  add: (id: string) => void;
};

const authSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = (set) => ({
  token: null,
  login: (token) => set({ token }),
});

const cartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = (set) => ({
  items: [],
  add: (id) => set((s) => ({ items: [...s.items, id] })),
});

export const useApp = create<AuthSlice & CartSlice>()((...a) => ({
  ...authSlice(...a),
  ...cartSlice(...a),
}));

Each slice owns its piece of state. The combined store is what components consume.

Middleware: persist and devtools

Zustand ships small middlewares. persist saves state to localStorage, devtools connects to the Redux DevTools extension.

import { persist, devtools } from 'zustand/middleware';

export const usePrefs = create<PrefsState>()(
  devtools(
    persist(
      (set) => ({
        theme: 'light',
        toggle: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
      }),
      { name: 'prefs' },
    ),
  ),
);

persist rehydrates on first read. You can scope what to save with partialize, and migrate schema versions with version plus migrate.

Reading outside React

Because the store is a plain function, you can read and write it from non-component code.

useCounter.getState().increment();
const unsub = useCounter.subscribe((s) => console.log(s.count));

This is useful in event handlers wired to libraries that do not know about React, or in tests.

Comparing to Context

React Context is fine for low-frequency values (theme, auth user). It is painful for anything that changes often because every consumer re-renders. Zustand sidesteps that by using selectors plus an external store. There is no provider, so no provider-driven re-render storm.

Compared to Redux Toolkit, Zustand drops the action and reducer split. You lose the time-travel debugging story unless you opt into devtools, but you gain a tenth the boilerplate.

When to use it

Reach for Zustand when:

  • You have global UI state (modals, drawers, theme) used across many components
  • You want a store without a provider wrapping the tree
  • Server state lives elsewhere (use a query library for that)
  • You want TypeScript that works without ceremony

Skip it when your state is local to a single component. useState is still the right default.

Wrap up

Zustand earns its place by doing less. A store is one function call, components subscribe with selectors, and middleware is opt-in. The discipline you need is small: write selectors that return the smallest slice you can, and keep server state in a query cache rather than the store. With that, Zustand scales from a side project to a large app without rewrites.