Quick Answer

Zustand is a small state-management library for React. You call create() with a function that returns your state plus the functions that change it, and you get back a hook. Components read the exact slice they need with a selector and re-render only when that slice changes. There is no context provider, no action constants, and no reducer - the whole library is about 1KB.

What Zustand takes away

A minimal Redux setup involves action type constants, action creators, reducers, a <Provider> at the root, and usually Redux Toolkit plus a thunk middleware for async. The mental model is: dispatch an action, a reducer computes the next state, connected components re-render.

Zustand collapses that. Your state and the functions that update it live together in a single create call. There is no provider, because the store is a plain module-level object you import anywhere. "Actions" are just functions that call set. Async is just an async function - no middleware required. You lose Redux's strict ceremony and its time-travel devtools by default (a devtools middleware adds them back), and you gain far less code per feature.

Zustand is not trying to replace Redux everywhere. It is a good fit when the ceremony is slowing you down more than it is helping.

Creating a store

Install with npm install zustand, then create a store:

import { create } from "zustand";

const useCounterStore = create((set, get) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  incrementBy: (n) => set((state) => ({ count: state.count + n })),
  reset: () => set({ count: 0 }),
}));

set updates the state. Pass it an object to merge in fixed values (set({ count: 0 })), or a function when the new value depends on the old one (set((state) => ({ count: state.count + 1 }))). get reads the current state from inside an action, which helps when one action needs to look at several fields.

The convention is to keep actions inside the store, next to the data they touch, rather than scattering setState calls through your components. A component should call increment(), not know how the count is stored.

Reading state with selectors

Read from the store by passing a selector function to the hook:

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

  return <button onClick={increment}>{count}</button>;
}

The component re-renders only when the selected value changes. A component that selects state.count will not re-render when an unrelated state.user updates - Zustand checks the selector's return value against its previous result and skips the render if they match.

Calling the hook with no selector - const store = useCounterStore() - subscribes to the entire store, so the component re-renders on every change to any field. Always select the narrowest thing you need. If you need several values, select them one at a time as above, or read the next section for the shortcut - and its trap.

The object-selector trap

It is tempting to grab several values in one selector by returning an object:

// Do not do this
const { count, increment } = useCounterStore((state) => ({
  count: state.count,
  increment: state.increment,
}));

That selector builds a new object every time it runs. Zustand compares the new result to the previous one with Object.is, sees a different reference, and re-renders. The re-render runs the selector again, producing another new object, another re-render. In Zustand v5 this does not just waste renders - it throws:

Warning: The result of getSnapshot should be cached to avoid an infinite loop
Error: Maximum update depth exceeded

The fix is useShallow, which compares the object's values instead of its reference:

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

const { count, increment } = useCounterStore(
  useShallow((state) => ({ count: state.count, increment: state.increment }))
);

In Zustand v4 the same mistake was a silent performance bug - the component re-rendered on every store change but nothing crashed. Version 5 surfaces it loudly, which is better, but it catches people mid-migration. Selecting primitives one per line never has this problem.

Updating nested state and async actions

set merges only the top level of your state. This bites when you update a nested object:

// state.user is { name: "Asha", theme: "light" }
set({ user: { theme: "dark" } });
// state.user is now { theme: "dark" } - name is gone

Because the merge is shallow, the whole user object is replaced, not patched. You have to spread the previous value yourself:

set((state) => ({
  user: { ...state.user, theme: "dark" },
}));

For deeply nested state, the immer middleware lets you write set((state) => { state.user.theme = "dark" }) and handles the copying. Async actions need nothing special - just an async function that calls set as results arrive:

fetchTodos: async () => {
  set({ loading: true, error: null });
  try {
    const res = await fetch("/api/todos");
    set({ todos: await res.json(), loading: false });
  } catch (err) {
    set({ error: String(err), loading: false });
  }
},

Middleware: persist and devtools

Zustand ships middleware you wrap around the store function. persist saves state to localStorage and restores it on the next page load:

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

const useAuthStore = create(
  persist(
    (set) => ({
      token: null,
      loading: false,
      setToken: (token) => set({ token }),
    }),
    {
      name: "auth", // localStorage key
      partialize: (state) => ({ token: state.token }),
    }
  )
);

partialize picks which fields to persist - here just token, not the transient loading flag. Without it, the entire store is written, and you can rehydrate a stale loading: true on the next load.

The devtools middleware connects the store to the Redux DevTools browser extension so you can inspect every update. Middleware composes by nesting: create(devtools(persist(fn, { name: "auth" }))). Order matters - put devtools outermost so it sees the changes the other middleware make.

Frequently Asked Questions

Is Zustand production-ready? Yes. It is used by large applications and has a stable v5 API. Its small size and lack of magic are the point, not a limitation.
Zustand or Redux Toolkit? Zustand for less ceremony, smaller apps, or per-feature stores. Redux Toolkit when you want strict conventions across a large team, time-travel debugging, or its ecosystem like RTK Query and entity adapters.
How do I reset a Zustand store between tests? Because the store is a module singleton, state leaks between tests. Call useStore.setState({ ...initialValues }) in beforeEach - a partial merge, so your action functions stay in place - or re-import the module fresh.
Can I have multiple Zustand stores? Yes, and it is encouraged - one store per domain such as auth, cart, and ui, rather than one giant store. Stores are fully independent.
How do I read Zustand state outside a React component? Call useStore.getState() for a one-time read, or useStore.subscribe(listener) to react to changes. Both work anywhere - event handlers, other modules, plain functions.