Zustand Is the State Manager React Should Have Had From the Start

May 22, 2026

Zustand Is the State Manager React Should Have Had From the Start
React11 min read

State management in React has always been more complicated than it needs to be. Redux came with actions, reducers, dispatchers, middleware, and a folder structure that took longer to set up than the feature you were building. Context worked for small things but re-rendered everything. Recoil and Jotai introduced atoms. MobX brought observables. At some point the question stopped being about state and started being about which philosophy you wanted to adopt.

Zustand skips all of that. It is a small library that gives you a store, a way to update it, and a hook to subscribe to it. That is the whole idea. This guide covers how it works, how it compares to the alternatives, and the patterns that keep a Zustand codebase clean as it grows.

What Problem Are We Solving?

Not all state is the same. Before choosing a tool, it helps to name the kinds of state in a typical app:

Type of stateExampleBest tool
Local UI stateIs this dropdown open?useState
Shared UI stateSidebar open, theme, modalsZustand
Server stateUsers, posts, products from an APIReact Query or SWR
Form stateInputs, validation, errorsReact Hook Form
URL stateFilters, page number, searchRouter search params

Most state management pain comes from using one tool for all five. Zustand is great at the second row and decent at several others, but it is not a replacement for everything.

How It Works

You create a store with create, define your state and the functions that update it, and then call it in your components with a selector.

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}))

function Counter() {
  const count = useStore((s) => s.count)
  const increment = useStore((s) => s.increment)
  return <button onClick={increment}>{count}</button>
}

That is a complete working example. No provider, no boilerplate, no file structure to follow. The component only re-renders when count changes because the selector is precise.

Why Not Just Context

React Context re-renders every component that consumes a context value whenever anything in that context changes. For simple things like a theme or the current user, that is fine. For anything that updates frequently, like form state, search queries, or UI state, it creates performance problems that are annoying to debug and fix.

Zustand uses subscriptions. Components only re-render when the specific slice of state they selected has changed. You get the simplicity of Context without the re-render problem.

Zustand vs The Alternatives

ZustandRedux ToolkitContextJotai
BoilerplateVery lowMediumLowLow
Provider neededNoYesYesOptional
Re-render controlSelectorsSelectorsPoorAtom-level
DevToolsYes (Redux DevTools)ExcellentNoYes
Learning curveMinutesDaysMinutesHours
Best forGlobal client stateLarge teams, strict structureRarely changing valuesFine-grained derived state

Redux Toolkit is still a legitimate choice for big teams that want strict conventions and powerful tooling. But for most products, it is more structure than the problem needs.

Selectors Done Right

The single most important habit in Zustand is selecting only what you need. Compare these two lines:

// subscribes to the whole store, re-renders on any change
const store = useStore()

// subscribes to one value, re-renders only when it changes
const count = useStore((s) => s.count)

When you need several values at once, do not return a new object from the selector, because that creates a new reference on every call. Use useShallow to compare the values instead of the reference:

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

const { count, increment } = useStore(
  useShallow((s) => ({ count: s.count, increment: s.increment }))
)

Async Actions

One thing Zustand does well that Redux made complicated is async. There is no middleware to configure. You just write an async function in your store.

const useUserStore = create((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      const user = await getUser(id)
      set({ user, loading: false })
    } catch (e) {
      set({ error: e.message, loading: false })
    }
  },
}))

That is it. No thunks, no sagas, no middleware chain to configure.

A Realistic Example: Shopping Cart

Here is a cart store with derived data and persistence. This is the kind of state that really belongs in a global store:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCart = create(
  persist(
    (set, get) => ({
      items: [],
      add: (product) =>
        set((s) => {
          const found = s.items.find((i) => i.id === product.id)
          if (found) {
            return {
              items: s.items.map((i) =>
                i.id === product.id ? { ...i, qty: i.qty + 1 } : i
              ),
            }
          }
          return { items: [...s.items, { ...product, qty: 1 }] }
        }),
      remove: (id) =>
        set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
      clear: () => set({ items: [] }),
      total: () =>
        get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
    }),
    { name: 'cart-storage' }
  )
)

With the persist middleware, the cart survives page refreshes because it is saved to localStorage automatically. No extra code in your components.

Middleware You Will Actually Use

  • persist: saves state to localStorage, sessionStorage, or a custom storage, with options to pick which fields to save.
  • devtools: connects your store to Redux DevTools so you can inspect and time-travel through changes.
  • immer: lets you write "mutating" updates that are converted to immutable ones, useful for deeply nested state.
  • subscribeWithSelector: lets you subscribe to changes outside React, for example to sync with analytics or a websocket.

Organizing Larger Stores

As an app grows, a single giant store becomes hard to read. There are two clean approaches:

  1. Multiple small stores. One for auth, one for UI, one for cart. Each file is short and focused. This is the simplest approach and works for most apps.
  2. The slices pattern. Split one store into functions that each return part of the state, then combine them. Useful when slices need to read each other.

Start with multiple small stores. Only reach for slices when you actually need cross-store access.

Using Zustand With Next.js

There is one trap in server-rendered apps. A store created at module level is shared by every request on the server, which can leak state between users. The recommended pattern for Next.js is to create the store per request inside a provider, or to keep stores strictly client-side and avoid reading them during server rendering.

Also watch for hydration mismatches with persist. The server renders the default state, while the client may load a different state from localStorage. Delay reading persisted values until after mount, or use the middleware's hydration options.

When to Use It

Zustand is a good fit for:

  • Global UI state such as sidebars, modals, themes, and toasts.
  • Shared data that multiple distant components need.
  • Anything that would otherwise require prop drilling through three or more levels.
  • Replacing Redux in codebases where Redux feels like overkill.

For server state, meaning data that comes from an API, you should still reach for React Query or SWR. Those libraries handle caching, revalidation, retries, and loading states better than a Zustand store you manage manually. Zustand and React Query together cover almost everything most apps need.

Common Mistakes

  • Calling useStore() without a selector, which makes every component re-render on every change.
  • Storing server data in Zustand and rebuilding a worse version of React Query.
  • Putting everything into one massive store.
  • Mutating state directly instead of returning new objects (unless you use immer).
  • Reading a store at module level on the server in Next.js.

The Ecosystem

Zustand has a middleware system for persistence, devtools integration, and immer support. The devtools work with Redux DevTools in the browser, so you can inspect your store the same way you would inspect a Redux store.

It is also tiny. Around 1kb gzipped. That matters less than it used to, but it is a signal that the library does not try to solve every problem, which is the reason it solves its actual problem so well.

The Verdict

If you are starting a new React project today and need shared client state, start with Zustand. Pair it with React Query for server data and a router for URL state, and you will rarely need anything else. You can learn it in an afternoon and still be using the same patterns two years later.

All articlesOussama Fannah