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
createcall. Components subscribe to slices and re-render only when that slice changes. - Simplicity: the entire API is one
createfunction and oneuseStorehook. 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, andsubscribeWithSelectorare 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) andloadablewrap 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
useAtomValueand 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
| Dimension | Zustand | Jotai |
|---|---|---|
| Mental model | Store (flat object + actions) | Atoms (individual reactive values) |
| Bundle size | ~1 KB | ~3 KB |
| Re-render control | Selector per component | Per-atom subscription; very fine-grained |
| Derived state | Selectors (may need memoization) | Derived atoms (automatic) |
| Async state | Middleware or external (TanStack Query) | Built-in async atoms, Suspense integration |
| Provider required | Only for SSR | Provider for isolation; can also be global |
| DevTools | Redux DevTools via middleware | Jotai DevTools extension |
| Colocated actions | Yes (in store definition) | Via atomWithReducer or atom families |
| Boilerplate | Very low | Low; can grow with atom families |
| Learning curve | Very low | Low; concept of atom families takes adjustment |
| TypeScript | Excellent inference | Excellent 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
seton 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)foruseAtomValue(valueAtom). Actions swap touseSetAtom(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.