mrkeyoor.com_
Wed 05 Aug 05:05 UTC
npmWeb Frontendupdated 04 Aug 2026

zustand

Zustand is a small state manager for React built around one idea: your store is a hook. You call create() with an object of state and actions, then any component subscribes to a slice of it with a selector. There are no providers wrapping the app, no reducers, and no action types unless you want them. The core is under 1 KB gzipped, works outside React through a vanilla store, and ships opt-in middleware for persistence, Immer, and Redux DevTools. The readme's claim that it handles the zombie-child and concurrency edge cases correctly has held up in practice.

Verdict

The best default for client state in React apps: tiny, fast, and hard to misuse at small scale. Keep server data in a query library and keep stores disciplined, and it stays pleasant as the app grows.

API stability4/5create, set, and get have been stable for years; the v4 to v5 major dropped React under 18 and default equality functions, which forced a useShallow migration in selector-heavy code.
Docs4/5zustand.docs.pmnd.rs covers middleware, TypeScript, testing, and SSR caveats well; advanced TypeScript with stacked middleware still sends you to GitHub discussions.
Maintenance5/5Actively maintained under the pmndrs collective with regular releases; last push within a week of this review.
Ecosystem4/5Massive adoption (about 50M weekly downloads) and a documented third-party library list; smaller plugin surface than Redux, but persist, devtools, and immer ship in the box.

Use it if

  • You have outgrown useState plus context prop-drilling but Redux Toolkit feels like too much ceremony for the app's size
  • You need components to re-render only when their selected slice changes, without wrapping the tree in providers
  • You need to read or write state outside React (websocket handlers, game loops, analytics) via getState, setState, and subscribe
  • You want extras like persist, devtools, and immer as composable middleware rather than a framework
Skip it if

Setup reality

npm install zustand and you are writing a store in five lines; there is genuinely no provider, config, or boilerplate step. The gotchas are conceptual instead: v5 removed default custom equality, so selectors returning fresh objects re-render on every change until you wrap them in useShallow; TypeScript needs the odd curried create<State>()(...) form; and persist plus SSR means hydration mismatches you handle with skipHydration or a mounted check. React 18 is the minimum peer dependency, and immer is a separate install if you use that middleware.

Patterns

Create a storecreate-store

import { create } from 'zustand'

const useBearStore = create((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  reset: () => set({ bears: 0 }),
}))

set merges top-level keys by default; it does not deep-merge nested objects.

Subscribe to one valueselect-slice

function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <h1>{bears} around here</h1>
}

Calling useBearStore() with no selector subscribes to the whole store and re-renders on every change.

Select multiple values without extra rendersselect-multiple-shallow

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

const { nuts, honey } = useBearStore(
  useShallow((s) => ({ nuts: s.nuts, honey: s.honey })),
)

Without useShallow the object literal is new every render, and since v5 that means a re-render every time.

Type a storetypescript-store

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((s) => ({ bears: s.bears + by })),
}))

The extra () after create<BearState> is required; it is the currying workaround that keeps middleware inference working.

Async actionasync-action

const useFishStore = create((set) => ({
  fishies: {},
  fetch: async (pond) => {
    const res = await fetch(pond)
    set({ fishies: await res.json() })
  },
}))

No thunks or middleware needed; just call set when the promise resolves.

Persist to localStorage or sessionStoragepersist-storage

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

const useFoodStore = create(
  persist(
    (set, get) => ({ fishes: 0, add: () => set({ fishes: get().fishes + 1 }) }),
    { name: 'food-storage', storage: createJSONStorage(() => sessionStorage) },
  ),
)

Default storage is localStorage; with SSR frameworks guard against hydration mismatch via skipHydration or a mounted flag.

Wire up Redux DevToolsdevtools

import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools((set) => ({
    fishes: 0,
    eat: () => set((s) => ({ fishes: s.fishes - 1 }), undefined, 'fish/eat'),
  })),
)

The third argument to set names the action in DevTools; otherwise everything logs as anonymous.

Mutate nested state with Immerimmer-middleware

import { immer } from 'zustand/middleware/immer'

const useStore = create(
  immer((set) => ({
    nested: { deep: { count: 0 } },
    bump: () => set((state) => { state.nested.deep.count += 1 }),
  })),
)

immer is a peer dependency you install separately.

Read and write state outside componentsread-outside-react

const paw = useDogStore.getState().paw
useDogStore.setState({ paw: false })
const unsub = useDogStore.subscribe(console.log)
unsub()

getState is a one-time snapshot, not reactive; components reading it will not update on changes.

Subscribe to a specific valuesubscribe-with-selector

import { subscribeWithSelector } from 'zustand/middleware'

const useDogStore = create(
  subscribeWithSelector(() => ({ paw: true, fur: true })),
)

useDogStore.subscribe(
  (s) => s.paw,
  (paw, prevPaw) => console.log(paw, prevPaw),
  { fireImmediately: true },
)

Plain subscribe fires on every state change; this middleware adds the selector plus equality signature.

Split a big store into slicesslices-pattern

const createBearSlice = (set) => ({
  bears: 0,
  addBear: () => set((s) => ({ bears: s.bears + 1 })),
})

const createFishSlice = (set) => ({
  fishes: 0,
  addFish: () => set((s) => ({ fishes: s.fishes + 1 })),
})

const useBoundStore = create((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
}))

Slices share one flat namespace; a key collision between slices overwrites silently.

Per-request store for SSR/RSCssr-per-request-store

import { createContext, useContext } from 'react'
import { createStore, useStore } from 'zustand'

const StoreContext = createContext(null)

// create per tree/request, not at module scope
const makeStore = () => createStore((set) => ({ count: 0 }))

function Counter() {
  const store = useContext(StoreContext)
  const count = useStore(store, (s) => s.count)
  return count
}

Module-level stores leak state across requests on the server; the docs recommend vanilla stores passed through context for Next.js 13+.

Alternatives

PackageRegistryPick it when
jotainpmYour state decomposes into many small derived atoms rather than one store
@reduxjs/toolkitnpmA large team needs enforced conventions, entity adapters, and mature devtools workflows
valtionpmYou prefer mutable proxy state you assign to directly instead of calling set