mrkeyoor.com_
Sat 19 Sept 15:52 UTC
npmWeb Frontendupdated 19 Sept 2026

zustand review

Zustand 5.0.15 is a React state library built on external stores, selector hooks, and a small set/get/subscribe API. The vanilla entry creates the same store without a React hook. Optional middleware handles browser persistence, Redux DevTools, Immer updates, selector subscriptions, and reducer-style dispatch. The current patch fixes DevTools caller detection when a source path contains spaces and prevents clearStorage from being undone by an asynchronous rehydration already in flight. Our peer-free Node 22 probe could install the package but could not load either module entry.

50.3Mdownloads / wk
Verdict

Zustand 5.0.15 installed as one 1 MB package in 1.4 seconds with zero audit findings, but require(), ESM import, and the browser bundle all failed in our peer-free Node 22 sandbox. Install the React peers and test your entry point first; use Zustand for client-owned state with narrow selectors, not as a server-data cache or cross-request singleton.

We installed it

Lab card: what happened when we installed zustandScreenshot of zustand documentation
Install✓ · 1.4s1 package on disk · 1 MB
ImportESM import fails · require() fails · CommonJS package with exports map
Browsern/acould not be bundled for the browser (Node-only code, most likely)
TypesTypeScript types bundled
Known vulns00 critical · 0 high · 0 moderate · 0 low (npm audit)

Answers from our run

Does zustand install cleanly?

Yes. In a fresh container with an empty cache, npm install zustand finished in 1 seconds, leaving 1 package and 1 MB on disk. npm audit reported no known vulnerabilities.

Can zustand run in a browser?

Not directly: esbuild could not bundle it for the browser in our run, which normally means it depends on Node built-ins. Use it on the server, or find a browser-targeted alternative.

Does zustand work with both ESM and CommonJS?

Neither plain import nor require succeeded in our sandbox, so it needs a bundler or extra setup.

Does zustand include TypeScript types?

Yes, type declarations ship inside the package, so no @types install is needed.

zustand or jotai: which should you use?

jotai: Choose it when state is easier to compose from independent atoms and derived dependencies. Zustand 5.0.15 installed as one 1 MB package in 1.4 seconds with zero audit findings, but require(), ESM import, and the browser bundle all failed in our peer-free Node 22 sandbox.

When should you not use zustand?

Most shared state comes from an API and needs retries, deduplication, stale-time rules, refetching, or mutation invalidation; TanStack Query owns that lifecycle

API stability4/5Zustand 5.0.15 keeps create, createStore, useStore, getState, setState, subscribe, and selector hooks as its core. Version 5 raised the React minimum to 18, removed custom equality support from create, required stable selector outputs, tightened replace typing, and changed initial persistence behavior. Those are meaningful migration points, while the 5.0.15 patch itself only repairs DevTools path parsing and a persistence race.
Docs5/5The Zustand 5 docs cover selector identity, useShallow, immutable nested updates, TypeScript currying, vanilla stores, persistence versions and migrations, DevTools, Immer, slices, testing, SSR, hydration, and a dedicated Next.js pattern. The Next.js guide directly says to avoid global stores and React Server Component access. Middleware combinations can still produce dense TypeScript signatures, but the relevant caveats are documented rather than hidden in issue threads.
Maintenance5/5pmndrs/zustand is unarchived, has 58,611 stars, and was pushed on 2026-08-24. GitHub reported only 5 open issues and pull requests combined. Release 5.0.15 arrived on 2026-08-13 and fixed a concurrent async rehydration race plus DevTools stack parsing for source paths containing spaces. The recent patch, small tracker, and continuing repository activity provide unusually clear maintenance evidence.
Ecosystem5/5npm recorded 53,641,912 Zustand downloads from 2026-08-19 through 2026-08-25, while the repository holds 58,611 stars. Official entry points support React hooks and vanilla stores, and middleware covers persistence, DevTools, selectors, Immer, and reducer-style state. React Query remains the better owner for remote data and Redux Toolkit offers more organizational rules, so Zustand's ecosystem is strongest around focused client state.

Discussed on

  1. hnZustand: Bear necessities for state management in React3 points
  2. hnShow HN: Realtime Multiplayer Middleware for Zustand3 points
  3. hnWorking with Zustand3 points

Use it if

  • Several React branches need to read and update the same client-owned state without one broad Context value rerendering them all
  • Sockets, timers, or other code outside React must use getState, setState, and subscribe on the same store
  • Selectors can stay narrow, and the team wants persistence or DevTools as optional middleware rather than framework-wide conventions
  • Server-rendered applications can create a vanilla store per request and keep React Server Components away from mutable store state
Skip it if

Setup reality

Our install of Zustand 5.0.15 finished in 1.4 seconds in a clean Node 22 Bookworm container. It left 1 package using 1 MB, and npm audit found zero vulnerabilities across critical, high, moderate, and low severities. Zustand declares 0 direct dependencies and 4 optional peers, is 288 KB unpacked, requires Node 12.20 or newer, and bundles TypeScript declarations.

The package declares CommonJS and provides an exports map with separate import targets. On Node.js 22.23.2, require() failed and ESM import failed in our package-only sandbox. The esbuild browser probe also failed, so we have no measured browser bundle size. A failed browser probe often points to Node-only code, but Zustand documents React and vanilla browser use. Since the run installed none of its 4 peers, this result does not isolate the failing peer or prove the library is Node-only.

React 18 or newer is the peer needed for hook use. use-sync-external-store is needed by the traditional equality APIs, and Immer is separate when the Immer middleware is imported. TypeScript stores commonly use create()(...) so middleware mutators keep their types. set performs a shallow top-level merge. Passing true as its replace flag requires a complete state and can remove action functions along with data.

Selectors compare by identity. Returning a new object, array, or fallback function on every call can cause extra renders and, under version 5 behavior, an infinite update loop. Use atomic selectors, useShallow, or a stable reference. persist defaults to localStorage and introduces hydration timing into server-rendered pages. Use skipHydration or a mounted-state plan, version stored data, and migrate old shapes. On a server, construct a vanilla store per request; React Server Components should neither read nor write it.

Patterns

Define state and actions together create-counter-store

import { create } from 'zustand'

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

set shallowly merges the returned object. Nested objects still need an immutable update or Immer.

Subscribe to individual store fields select-atomic-fields

function CounterButton() {
  const count = useCounter((state) => state.count)
  const increment = useCounter((state) => state.increment)
  return <button onClick={increment}>{count}</button>
}

Calling useCounter without a selector subscribes the component to every state change. Atomic selectors keep the subscription narrow.

Select several fields with shallow comparison select-shallow-object

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

const { count, reset } = useCounter(
  useShallow((state) => ({
    count: state.count,
    reset: state.reset,
  })),
)

A fresh object has a new identity on every run. useShallow reuses the prior result when its top-level fields are unchanged.

Keep middleware-friendly TypeScript inference type-store-actions

import { create } from 'zustand'

type CartState = {
  items: number
  add: (count: number) => void
}

export const useCart = create<CartState>()((set) => ({
  items: 0,
  add: (count) => set((state) => ({ items: state.items + count })),
}))

The double call after create<CartState>() is the documented TypeScript form and leaves room for middleware mutator types.

Update state after an asynchronous call run-async-action

const useProfile = create((set) => ({
  user: null,
  loading: false,
  load: async (id) => {
    set({ loading: true })
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) throw new Error('profile request failed')
    set({ user: await response.json(), loading: false })
  },
}))

Zustand does not add cancellation, retries, deduplication, or freshness policy. Use a query library when those rules are required.

Persist one slice to sessionStorage persist-selected-state

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

const useDraft = create(
  persist(
    (set) => ({ text: '', setText: (text) => set({ text }) }),
    {
      name: 'draft-v2',
      storage: createJSONStorage(() => sessionStorage),
      partialize: (state) => ({ text: state.text }),
    },
  ),
)

Browser storage is unavailable during server rendering. Plan hydration before persisted values affect visible HTML.

Version and migrate stored data migrate-persisted-state

persist(
  (set) => ({ position: { x: 0, y: 0 } }),
  {
    name: 'canvas-position',
    version: 2,
    migrate: (stored, version) =>
      version === 1
        ? { position: { x: stored.x, y: stored.y } }
        : stored,
  },
)

Without migrate, persisted state from another version is skipped. Validate stored input before trusting its shape.

Clear storage and reset live state clear-persisted-data

await useDraft.persist.clearStorage()
useDraft.setState({ text: '' })

clearStorage removes the persisted item but does not reset memory. Version 5.0.15 also invalidates an async rehydration already in progress.

Run code when one field changes subscribe-selected-value

import { subscribeWithSelector } from 'zustand/middleware'

const usePresence = create(
  subscribeWithSelector(() => ({ online: false, userId: null })),
)

const unsubscribe = usePresence.subscribe(
  (state) => state.online,
  (online, previous) => console.log({ online, previous }),
  { fireImmediately: true },
)

The selector overload on subscribe requires subscribeWithSelector. Call the returned function when the listener is no longer needed.

Edit nested state through Immer middleware update-with-immer

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

const useVisits = create(
  immer((set) => ({
    profile: { stats: { visits: 0 } },
    recordVisit: () => set((draft) => {
      draft.profile.stats.visits += 1
    }),
  })),
)

Install Immer separately before using this entry point. A producer should mutate the draft or return replacement state, not do both.

Read and update a store outside React use-vanilla-store

import { createStore } from 'zustand/vanilla'

export const connectionStore = createStore((set) => ({
  connected: false,
  setConnected: (connected) => set({ connected }),
}))

const stop = connectionStore.subscribe((state) => {
  console.log(state.connected)
})
connectionStore.getState().setConnected(true)
stop()

A vanilla store has getState, setState, subscribe, and getInitialState. It does not create a React hook.

Create a new store for each rendered tree scope-store-per-request

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

const StoreContext = createContext(null)
const makeStore = (count) => createStore(() => ({ count }))

export function StoreProvider({ initialCount, children }) {
  const ref = useRef()
  if (!ref.current) ref.current = makeStore(initialCount)
  return <StoreContext.Provider value={ref.current}>{children}</StoreContext.Provider>
}

export const useCount = () => useStore(useContext(StoreContext), (s) => s.count)

Create the vanilla store per request or mounted provider. A server module singleton can expose one user's mutable state to another request.

Alternatives

PackageRegistryPick it when
jotainpmChoose it when state is easier to compose from independent atoms and derived dependencies
@reduxjs/toolkitnpmChoose it when team-scale conventions, reducers, entity helpers, and a standard middleware model matter
valtionpmChoose it when proxy-backed mutable-looking objects are clearer to the team than explicit set calls
@tanstack/react-querynpmChoose it for remote server data that needs caching, refetching, retries, and mutation invalidation

More web frontend guides

postcss · react · react-dom · tailwindcss · htmlparser2 · tailwind-merge · the whole shelf →

How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.