· zustand / jotai / react

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

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.13Jotai v2.20.0
Weekly downloads37M3.7M
GitHub stars58,06521,168
Bundle (gzip)~3 KB~4 KB
Mental modelExternal store, top-downAtomic, bottom-up
Re-rendersManual selector disciplineAutomatic per-atom
AsyncManual fetch + setNative Suspense
DevToolsBuilt-in middlewareSeparate jotai-devtools package
TypeScriptExplicit generic at creationInferred from init value
Outside-React accessuseBearStore.getState()createStore() + vanilla API
Best fitGlobal state, event handlers, WebSocketsDerived 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:

ApproachTotal rendersUnnecessary renders
Zustand (broad selector)93 (33% wasteful)
Zustand (precise selector)60
Jotai60 (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:

MetricZustandJotai
Single update render time12ms14ms
Memory usage2.1 MB1.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

FeatureZustandJotai
Redux DevToolsBuilt-in middlewareVia useAtomDevtools hook
Persistencepersist middlewareatomWithStorage
Immerimmer middlewarejotai-immer
TanStack Query bridgeUse separatelyatomWithQuery (jotai-tanstack-query)
XStateUse separatelyjotai-xstate
tRPCUse separatelyjotai-trpc
Optics / lensesNonejotai-optics
SSR / Next.jsStore factory patternProvider per request
React NativeYesYes (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 useShallow to avoid infinite re-renders
  • setState(state, true) requires the full state object when replace: true
  • React 18 minimum; TypeScript 4.5 minimum
  • persist middleware no longer auto-initializes; explicit setState required 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 awaitatom(async (get) => await get(asyncAtom))
  • WritableAtom type signature changed: WritableAtom<Value, Args extends unknown[], Result>
  • Provider.initialValues removed; use useHydrateAtoms
  • Provider.scope removed; use a custom React Context
  • jotai/babel moved to separate jotai-babel package (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 + Context incrementally 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 atomWithStorage can cause hydration mismatches in Next.js — server renders the initial value, client reads from localStorage. 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