Overview

Use Zustand when your client state is a flat object with a few properties and actions, such as auth state, UI flags, or a shopping cart. Use Jotai when state is naturally atomic: independent pieces that compose into derived values, graph-like subscriptions, or fine-grained re-render control across many components. Both live in the same weight class (under 3 KB gzipped); the choice is structural, not a performance bet. See react-state-management for where each fits in the state hierarchy.

When Zustand wins

Zustand is the right choice when state has a clear store shape.

  • Store metaphor: Zustand is a module-scoped store. Define state and actions together in one create call. Components subscribe to slices and re-render only when that slice changes.
  • Simplicity: the entire API is one create function and one useStore hook. New contributors learn it in minutes.
  • Actions colocated: mutations live in the store definition, not scattered in components. This makes state transitions easy to audit.
  • Middleware: persist (localStorage), devtools (Redux DevTools), immer, and subscribeWithSelector are opt-in and well-maintained.
  • No provider for most uses: stores are plain JavaScript modules. Import and use. Provider wrapping is only needed for SSR per-request isolation.
  • Suitable for: auth state, theme, modal state, form dirty tracking, multi-step wizard state, cart contents.
const useCart = create<CartStore>((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  clear: () => set({ items: [] }),
}));

When Jotai wins

Jotai is the right choice when state is naturally decomposed into independent atoms with derived relationships.

  • Atom model: each piece of state is an atom. Components subscribe to exactly the atoms they need; unrelated atoms changing does not trigger re-renders.
  • Derivation graphs: atom((get) => get(priceAtom) * get(quantityAtom)) creates a derived atom that recalculates only when its inputs change. Complex derived state is easy to compose and test in isolation.
  • Fine-grained subscriptions: in large component trees where many components read different parts of state, Jotai avoids the selector boilerplate that Zustand requires for the same effect.
  • Async atoms: atomWithQuery (Jotai TanStack Query integration) and loadable wrap async state naturally. The result is a first-class async atom rather than a loading flag on a store.
  • Suspense-first: Jotai integrates with React Suspense for async atom loading. Components read atoms with useAtomValue and suspend automatically.
  • Suitable for: spreadsheet-like UIs, form fields with cross-field validation, data tables with per-row state, editor panels with many independent controls.
const priceAtom = atom(100);
const quantityAtom = atom(1);
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom));

Trade-offs at a glance

DimensionZustandJotai
Mental modelStore (flat object + actions)Atoms (individual reactive values)
Bundle size~1 KB~3 KB
Re-render controlSelector per componentPer-atom subscription; very fine-grained
Derived stateSelectors (may need memoization)Derived atoms (automatic)
Async stateMiddleware or external (TanStack Query)Built-in async atoms, Suspense integration
Provider requiredOnly for SSRProvider for isolation; can also be global
DevToolsRedux DevTools via middlewareJotai DevTools extension
Colocated actionsYes (in store definition)Via atomWithReducer or atom families
BoilerplateVery lowLow; can grow with atom families
Learning curveVery lowLow; concept of atom families takes adjustment
TypeScriptExcellent inferenceExcellent inference

Migration cost

Zustand to Jotai migration is not a codemod task. The two models are different enough that migration requires intentional redesign of the state shape.

  • Identify atoms: each top-level property in a Zustand store becomes a candidate atom. Actions become functions that call set on the relevant atoms.
  • Derived values map naturally to derived atoms. This is usually where Jotai pays off most visibly.
  • Per-component: swap useStore(s => s.value) for useAtomValue(valueAtom). Actions swap to useSetAtom(setValueAtom).
  • Effort: one engineer-day per 5 to 10 Zustand store files, assuming no complex middleware.

Both libraries coexist in the same app; migrating incrementally is safe.

Recommendation

  • New app with standard UI state (auth, modals, cart): Zustand.
  • New app with complex derived state, spreadsheet-like UIs, or many independent subscriptions: Jotai.
  • Existing Zustand app that is working: keep it. Do not migrate for its own sake.
  • Pair either library with TanStack Query for server state; see tanstack-query-vs-swr.
  • When unsure: Zustand first. Its simpler mental model pays off in most cases.