Zustand vs Jotai: the 2026 state management comparison
Zustand for global state you update anywhere; Jotai for derived state that stays fast. Benchmark data, API diff, and the specific workloads where each wins.
By Ethan
2,390 words · 12 min read
Use Zustand for global app state and anything you need to update outside of React. Use Jotai when you have complex derived state and want automatic re-render granularity without selector discipline. That’s the verdict — the detail below covers when that advice reverses and why the two libraries can coexist in the same codebase.
Who this is for
React engineers choosing a minimalist state manager after outgrowing useState + Context, or those who have been using Redux and want to drop 90% of the ceremony. If you’re already on Zustand or Jotai and wondering whether to migrate, the migration paths and bridge section apply directly.
This is not for teams building simple single-form apps. For anything that fits in one useReducer, you don’t need either library.
What we compared
Zustand v5.0.13 (released May 5 2026) against Jotai v2.20.0 (released May 6 2026). Both are maintained under the pmndrs (Poimandres) open-source collective — the same team, different tools. Measurements and data points sourced from npm’s official download API, GitHub’s API, and third-party benchmarks from 2025–2026.
Zustand vs Jotai at a glance
| Zustand v5.0.13 | Jotai v2.20.0 | |
|---|---|---|
| Weekly downloads | 37M | 3.7M |
| GitHub stars | 58,065 | 21,168 |
| Bundle (gzip) | ~3 KB | ~4 KB |
| Mental model | External store, top-down | Atomic, bottom-up |
| Re-renders | Manual selector discipline | Automatic per-atom |
| Async | Manual fetch + set | Native Suspense |
| DevTools | Built-in middleware | Separate jotai-devtools package |
| TypeScript | Explicit generic at creation | Inferred from init value |
| Outside-React access | useBearStore.getState() | createStore() + vanilla API |
| Best fit | Global state, event handlers, WebSockets | Derived state, forms, Suspense-heavy UIs |
The mental models are genuinely different
Zustand and Jotai sit at opposite ends of the same tradeoff. Jotai’s creator @dai-shi described it directly: “Jotai is close to Recoil. Zustand is close to Redux.”
Zustand keeps state in an external store that lives outside the React tree. You connect components to it via a hook with a selector. The store is one object; you shape it, and selectors carve out the slice each component needs.
Jotai puts state inside the React tree as individual atoms. Components subscribe to exactly the atoms they read. Derived atoms — atoms that compute from other atoms — re-evaluate only when their upstream atoms change, and only the components subscribed to those derived atoms re-render.
Neither is objectively better. They optimize for different failure modes: Zustand optimizes for ergonomics when you need to act on state from anywhere; Jotai optimizes for correctness when state has a lot of derived layers.
API shape
Counter — side by side
Zustand:
// store.ts
import { create } from 'zustand'
interface CounterStore {
count: number
inc: () => void
}
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}))
// Component.tsx
const count = useCounterStore((s) => s.count)
const inc = useCounterStore((s) => s.inc)
Jotai:
// atoms.ts
import { atom } from 'jotai'
const countAtom = atom(0)
// Component.tsx
import { useAtom } from 'jotai'
const [count, setCount] = useAtom(countAtom)
// setCount((c) => c + 1)
Jotai’s counter is shorter. But Zustand’s is where co-locating actions in the store starts paying off: once your store has 10 fields and 15 actions, having them in one create() block with explicit types is easier to navigate than 15 scattered atom files.
Async data fetching — side by side
Zustand:
const useUserStore = create<{ user: User | null; load: () => void }>((set) => ({
user: null,
load: async () => {
const res = await fetch('/api/user')
set({ user: await res.json() })
},
}))
// Must call load() manually; no Suspense by default
const { user, load } = useUserStore()
useEffect(() => { load() }, [])
Jotai:
const userAtom = atom(async (_, { signal }) => {
const res = await fetch('/api/user', { signal })
return res.json() as Promise<User>
})
// Wrap component in <Suspense> — zero manual loading state
const [user] = useAtom(userAtom)
Jotai’s async atom integrates with React Suspense out of the box. You write the fetch, wrap the component in <Suspense>, done. No loading flag, no useEffect to kick off the request. Zustand’s approach is more explicit — which is a feature if you need to control when fetching starts.
Re-render behavior
This is where the architectural difference is most concrete. Jotai’s per-atom subscription model means a component subscribed to usernameAtom never re-renders when notificationCountAtom changes. That’s guaranteed by construction.
In Zustand, the selector determines re-render frequency. Get it right and you match Jotai’s efficiency. Get it wrong and every subscriber to the whole store re-renders on every change.
From a 2025 benchmark by Rohit Imandi testing components subscribed to different state slices, with one slice changing:
| Approach | Total renders | Unnecessary renders |
|---|---|---|
| Zustand (broad selector) | 9 | 3 (33% wasteful) |
| Zustand (precise selector) | 6 | 0 |
| Jotai | 6 | 0 (automatic) |
The broad-selector case added +7,506 ms of computational waste in a processing-heavy scenario. Jotai matched the optimally tuned Zustand automatically.
A separate 2026 stress test on dev.to with 1,000 components showed a different angle:
| Metric | Zustand | Jotai |
|---|---|---|
| Single update render time | 12ms | 14ms |
| Memory usage | 2.1 MB | 1.8 MB |
Zustand wins on raw throughput by ~2ms. Jotai uses ~14% less memory. Neither difference matters much in practice — they’re noise compared to network latency or a slow render tree elsewhere in your app.
The honest summary: Jotai is safer by default; Zustand matches it if you enforce selector discipline. If your team is disciplined about selectors, Zustand’s raw-throughput edge tips slightly in its favor. If your team has ever shipped a useStore() with no selector on a frequently-updating store, Jotai removes the footgun.
One Zustand-specific tool worth knowing: useShallow (v5), required when your selector returns an object or array.
import { useShallow } from 'zustand/react/shallow'
// Without useShallow: new object reference on every render
const { name, age } = useStore(useShallow((s) => ({ name: s.name, age: s.age })))
Forgetting useShallow in v5 causes components that subscribe to object-shaped selectors to re-render on every store update. Jotai doesn’t have this class of bug because the atom itself is the stable reference.
DevTools and ecosystem
DevTools
Zustand ships devtools middleware out of the box. It integrates with the Redux DevTools Extension and supports time-travel, action logging, and state snapshots.
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useStore = create(devtools((set) => ({
count: 0,
inc: () => set({ count: 1 }, false, 'inc'), // 'inc' names the action in devtools
})))
Jotai requires a separate jotai-devtools package — npm install jotai-devtools. It provides a visual UI component that embeds in the app (not the browser extension), plus five debugging hooks including useAtomDevtools for connecting individual atoms to the Redux DevTools Extension, and useGotoAtomsSnapshot for time-travel.
If your team already has the Redux DevTools Extension installed and configured, Zustand’s built-in integration is the easier path. Jotai-devtools is more capable for atom-level debugging but requires more setup.
Ecosystem comparison
| Feature | Zustand | Jotai |
|---|---|---|
| Redux DevTools | Built-in middleware | Via useAtomDevtools hook |
| Persistence | persist middleware | atomWithStorage |
| Immer | immer middleware | jotai-immer |
| TanStack Query bridge | Use separately | atomWithQuery (jotai-tanstack-query) |
| XState | Use separately | jotai-xstate |
| tRPC | Use separately | jotai-trpc |
| Optics / lenses | None | jotai-optics |
| SSR / Next.js | Store factory pattern | Provider per request |
| React Native | Yes | Yes (with AsyncStorage) |
Jotai has a broader extension ecosystem. Zustand has first-class middleware for the most common cases (devtools, persist, immer) and relies on the rest of the React ecosystem for everything else. Neither approach is wrong — the question is whether you want the integrations baked into the library or handled externally. Both are client-state solutions; for the server-state layer alongside either one — caching, revalidation, deduplication — TanStack Query vs SWR covers the options.
TypeScript DX
Zustand v5 requires an explicit generic at store creation.
// Explicit generic — easy to forget, required for type safety
const useStore = create<{ count: number; inc: () => void }>()((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}))
// Selector types are inferred from the store type
const count: number = useStore((s) => s.count)
Jotai infers types from the initial value.
const countAtom = atom(0) // Atom<number> — inferred
const userAtom = atom<User | null>(null) // explicit when needed
// Derived atoms are fully inferred
const doubledAtom = atom((get) => get(countAtom) * 2) // Atom<number>
// Async atoms resolve Promise type
const dataAtom = atom(async () => fetch('/api').then((r) => r.json() as Data)) // Atom<Data>
Jotai’s inference is cleaner for simple atoms. Both libraries are fully type-safe at the point of use. The main TypeScript pain point in Zustand is middleware stacking — devtools(persist(immer(...))) generates deeply nested generic types that can make error messages hard to read. Jotai’s composability via derived atoms avoids that pattern.
Migration paths
From Redux to Zustand
The mental models are close: single global store, action-based updates, DevTools support. The main shift is removing dispatch, action creators, and reducers — you call set() directly. Migrate slice by slice, replacing each Redux slice with a Zustand store. Learning curve is measured in hours, not days. For a head-to-head comparison with Redux Toolkit specifically, Zustand vs Redux Toolkit covers the migration in detail.
From Redux to Jotai
More of a paradigm shift. The centralized store becomes distributed atoms; Redux selectors become derived atoms. For large codebases, plan for 1–3 days to retrain the team. The payoff is automatic re-render optimization and simpler async patterns. Start with new features in Jotai and migrate legacy slices as you touch them.
From useState + Context to either
Zustand: replace useContext(StateContext) with useStore(selector). Drop the Provider. Both components access the same external store instance.
Jotai: replace useState calls with useAtom(atom). Almost no change to component structure. Opt-in to Provider for isolated subtrees.
Between Zustand and Jotai
The jotai-zustand package provides atomWithStore(store) — a two-way binding between a Zustand store and a Jotai atom.
import { createStore } from 'zustand/vanilla'
import { atomWithStore } from 'jotai-zustand'
import { useAtom } from 'jotai'
const zustandStore = createStore(() => ({ count: 0 }))
const storeAtom = atomWithStore(zustandStore)
const Counter = () => {
const [state, setState] = useAtom(storeAtom)
return (
<button onClick={() => setState((prev) => ({ count: prev.count + 1 }))}>
{state.count}
</button>
)
}
Updates via setState (Jotai) sync back to the Zustand store automatically, and vice versa. This lets you migrate incrementally rather than all at once.
v5 / v2 breaking changes
If you’re upgrading, both libraries had major breaking versions.
Zustand v5 (October 2024):
- Default exports removed — use
import { create } from 'zustand' - Selectors returning objects or arrays must use
useShallowto avoid infinite re-renders setState(state, true)requires the full state object whenreplace: true- React 18 minimum; TypeScript 4.5 minimum
persistmiddleware no longer auto-initializes; explicitsetStaterequired post-creation
Full guide: zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5
Jotai v2 (March 2023, now on v2.20.0):
- Async atoms require explicit
await—atom(async (get) => await get(asyncAtom)) WritableAtomtype signature changed:WritableAtom<Value, Args extends unknown[], Result>Provider.initialValuesremoved; useuseHydrateAtomsProvider.scoperemoved; use a custom React Contextjotai/babelmoved to separatejotai-babelpackage (v2.18.0, February 2026)
Full guide: jotai.org/docs/guides/migrating-to-v2-api
When to use both
The jotai-zustand bridge is more than a migration tool. Two patterns where both genuinely coexist:
Global + local split: Zustand for app-wide state (auth session, feature flags, cart), Jotai for complex local derived state (filter combinations, form field interdependencies, computed dashboard views). Each library handles the domain it’s better at.
Incremental migration: New features ship in Jotai; existing Zustand stores stay untouched until you’re ready. The bridge keeps both in sync without a flag day.
Verdict
Pick Zustand if:
- Your state is primarily global and flat (auth, user prefs, cart, feature flags)
- You need to update state from outside React — event listeners, WebSocket handlers, background jobs
- Your team knows Redux and wants minimal retraining
- You want Redux DevTools built-in with no extra setup
- You need to ship this week
Pick Jotai if:
- You have complex derived state where many atoms compute from each other
- Your UI has 30+ interdependent form fields or filter combinations where re-render frequency matters
- You’re building a feature heavily reliant on React Suspense for async data
- You want automatic re-render optimization with no selector discipline required
- You’re replacing
useState + Contextincrementally and want minimal structural changes
Both are ~3–4 KB gzip, both are pmndrs-maintained with near-zero open issues, and both have been stable since their v5/v2 releases. There is no wrong choice for a standard CRUD dashboard. The difference shows up under load on derived-state-heavy UIs, or when you need to act on state from outside the React tree.
Caveats
- The 1,000-component stress test numbers (12ms vs 14ms) are from a single benchmark author on a single machine. Treat them as directional, not absolute.
- Jotai’s
atomWithStoragecan cause hydration mismatches in Next.js — server renders the initial value, client reads fromlocalStorage. Guarded by default (getOnInit: false), but verify your setup if you’re doing SSR with persisted atoms. - Neither library has an affiliate program; this article contains no paid placements.
References
- npm downloads: zustand
- npm downloads: jotai
- GitHub: pmndrs/zustand
- GitHub: pmndrs/jotai
- Zustand docs
- Zustand v5 migration guide
- Jotai docs
- Jotai v2 migration guide
- Jotai devtools
- jotai-zustand bridge
- Jotai vs Zustand — creator’s comment
- Zustand v5 announcement
- Re-render granularity benchmark (Rohit Imandi, 2025)
- State management stress test 2026 (dev.to)