Skip to main content
← All Articles · · 9 min read

Zustand vs Jotai vs Recoil: React State Management Compared 2026

Compare Zustand, Jotai, and Recoil for React state management. Learn when to use each library, with performance tips and migration guidance.

reactstate-managementzustandjotaijavascript

React’s ecosystem has never lacked for state management options. But in 2026, three lightweight alternatives to Redux have solidified their positions: Zustand, Jotai, and Recoil. Each solves a different problem, and picking the wrong one costs refactoring time. This guide cuts through the noise with real patterns, performance data, and migration guidance.


The State of State Management in 2026

Redux Toolkit still holds a large share of enterprise codebases, but new projects increasingly reach for simpler solutions. The reasons are practical:

  • Bundle size matters: Zustand ships ~3KB, Jotai ~3KB, Recoil ~21KB gzipped.
  • Boilerplate fatigue: Developers want atoms and stores, not action types and reducers.
  • React 19 Concurrent Mode is now the default, and all three libraries handle Suspense and transitions natively.

Let’s look at each in depth.


Zustand: Minimal Store, Maximum Flexibility

Zustand (German for “state”) is built around a single create function that returns a hook. No providers, no context overhead — just a plain JavaScript object with methods.

Basic Setup

npm install zustand
// store/useCounterStore.ts
import { create } from 'zustand'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}))
// Component usage — no Provider needed
function Counter() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)

  return <button onClick={increment}>{count}</button>
}

The selector pattern (state) => state.count is crucial. Components only re-render when their selected slice changes, not on any store update.

Async Actions and Middleware

Zustand handles async naturally inside actions:

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface UserStore {
  user: User | null
  loading: boolean
  fetchUser: (id: string) => Promise<void>
}

const useUserStore = create<UserStore>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        loading: false,
        fetchUser: async (id) => {
          set({ loading: true })
          try {
            const user = await api.getUser(id)
            set({ user, loading: false })
          } catch {
            set({ loading: false })
          }
        },
      }),
      { name: 'user-storage' }
    )
  )
)

The persist middleware serializes state to localStorage automatically. The devtools middleware integrates with Redux DevTools.

Slices Pattern for Large Apps

When stores grow, split them into slices:

// store/slices/cartSlice.ts
import { StateCreator } from 'zustand'

export interface CartSlice {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
}

export const createCartSlice: StateCreator<CartSlice> = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
})

// store/useAppStore.ts
import { create } from 'zustand'
import { createCartSlice, CartSlice } from './slices/cartSlice'
import { createUserSlice, UserSlice } from './slices/userSlice'

type AppStore = CartSlice & UserSlice

export const useAppStore = create<AppStore>()((...args) => ({
  ...createCartSlice(...args),
  ...createUserSlice(...args),
}))

Jotai: Atomic State for Fine-Grained Reactivity

Jotai takes a bottom-up approach. Instead of one global store, state is split into atoms — individual units that components subscribe to independently. This is the same mental model as Recoil, but Jotai is lighter and requires no mandatory Provider for basic use.

Basic Atoms

npm install jotai
// atoms/counterAtom.ts
import { atom } from 'jotai'

export const countAtom = atom(0)
export const doubleCountAtom = atom((get) => get(countAtom) * 2)
import { useAtom, useAtomValue } from 'jotai'
import { countAtom, doubleCountAtom } from './atoms/counterAtom'

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const doubled = useAtomValue(doubleCountAtom)

  return (
    <div>
      <p>Count: {count} | Doubled: {doubled}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  )
}

doubleCountAtom is a derived atom — it recalculates whenever countAtom changes. Only components that consume doubleCountAtom re-render.

Async Atoms with Suspense

Jotai’s async atoms work natively with React Suspense:

import { atom } from 'jotai'
import { Suspense } from 'react'

const userIdAtom = atom<string>('user-123')

const userAtom = atom(async (get) => {
  const id = get(userIdAtom)
  const res = await fetch(`/api/users/${id}`)
  return res.json()
})

function UserProfile() {
  const user = useAtomValue(userAtom) // Suspends until resolved
  return <div>{user.name}</div>
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  )
}

Atom Families

When you need dynamic atoms (e.g., per-item state in a list), use atomFamily:

import { atomFamily } from 'jotai/utils'

const todoAtomFamily = atomFamily((id: string) =>
  atom({ id, text: '', completed: false })
)

function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))
  // ...
}

Recoil: Selector Graphs for Complex Dependencies

Recoil, originally from Meta, introduces the most sophisticated data model of the three. Its power comes from selector graphs — a directed acyclic graph of atoms and selectors where derived state is automatically memoized and invalidated.

Atoms and Selectors

npm install recoil
// App must be wrapped in RecoilRoot
import { RecoilRoot } from 'recoil'

function App() {
  return (
    <RecoilRoot>
      <Dashboard />
    </RecoilRoot>
  )
}
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'

const cartItemsAtom = atom<CartItem[]>({
  key: 'cartItems',
  default: [],
})

const cartTotalSelector = selector({
  key: 'cartTotal',
  get: ({ get }) => {
    const items = get(cartItemsAtom)
    return items.reduce((sum, item) => sum + item.price * item.qty, 0)
  },
})

const taxSelector = selector({
  key: 'cartTax',
  get: ({ get }) => {
    const total = get(cartTotalSelector)
    return total * 0.1
  },
})

taxSelector depends on cartTotalSelector, which depends on cartItemsAtom. Recoil tracks this graph — update cartItemsAtom and both selectors recompute in the correct order.

Async Selectors

const productDetailsSelector = selectorFamily({
  key: 'productDetails',
  get: (productId: string) => async () => {
    const res = await fetch(`/api/products/${productId}`)
    return res.json()
  },
})

Recoil’s selectorFamily is the equivalent of Jotai’s atomFamily, but it participates fully in the selector graph.


Performance Comparison

MetricZustandJotaiRecoil
Bundle size (gzip)~3KB~3KB~21KB
Re-render granularityPer selectorPer atomPer atom/selector
Concurrent ModeYesYesYes
DevToolsRedux DevToolsJotai DevToolsBuilt-in
SSR supportYesYesPartial

Re-render granularity is where Jotai and Recoil shine. Because each atom is an independent subscription, a component that reads only userNameAtom will not re-render when cartItemsAtom changes — even if both atoms live in the “same” application state. Zustand achieves similar granularity through disciplined use of selectors, but it requires developer effort rather than being structural.

Large selector graphs (50+ interdependent derived values) are where Recoil’s architecture pays off. The automatic memoization and topological evaluation order avoids wasteful recalculations that you’d have to manage manually in Zustand or Jotai.


When to Use Each Library

Choose Zustand when:

  • You need a simple global store without much derived state
  • Your team values familiarity — the pattern resembles Redux without the boilerplate
  • You want middleware (logging, persistence, devtools) out of the box
  • You’re building a dashboard or form-heavy app where state is mostly flat

Choose Jotai when:

  • You want atomic state that co-locates with components
  • Your app has many independent pieces of state with occasional dependencies
  • You’re using React Suspense heavily for data fetching
  • You want the lightest possible bundle with a Recoil-like model

Choose Recoil when:

  • You have complex derived state with deep dependency chains
  • You’re working on a large team that benefits from Recoil’s explicit key naming
  • You need fine-grained subscriptions across many components reading overlapping state
  • Your app originated from a Meta-style architecture (Relay, GraphQL fragments)

Vs Redux Toolkit

Redux Toolkit (RTK) remains the best choice for:

  • Large enterprise codebases with strict state change auditing requirements
  • Apps that need RTK Query for server state with normalized caching
  • Teams that already know Redux and want to reduce boilerplate without a migration

If you’re starting fresh in 2026, the overhead of actions, reducers, and selectors in RTK is harder to justify unless you need RTK Query’s normalized cache invalidation. For most new projects, Zustand covers 80% of RTK use cases at a fraction of the conceptual overhead.


Vs React Query for Server State

A common mistake is using Zustand/Jotai/Recoil to cache API responses. Server state is not client state. Server state has different concerns: staleness, background refetching, cache invalidation, and optimistic updates.

The right separation in 2026:

Server state  → TanStack Query (React Query) or SWR
Client state  → Zustand, Jotai, or Recoil

A common pattern pairs Zustand with TanStack Query:

// Server state: TanStack Query handles fetching, caching, revalidation
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
})

// Client state: Zustand handles UI-only state
const sidebarOpen = useUIStore((state) => state.sidebarOpen)

Trying to replicate TanStack Query’s behavior in a Zustand store leads to reinventing background refresh, stale-while-revalidate, and request deduplication — work that TanStack Query already handles.


Migration Path from Redux

If you’re migrating from Redux to Zustand:

  1. Identify leaf reducers — start with the simplest ones
  2. Convert each reducer to a Zustand store slice — actions become methods
  3. Replace useSelector + useDispatch with the Zustand hook
  4. Remove the Redux Provider once all stores are migrated

The slice pattern in Zustand maps cleanly to Redux slices, making incremental migration practical without a full rewrite.


Summary

ZustandJotaiRecoil
Best forGlobal store, familiar APIAtomic, component-localComplex derived state
Learning curveLowLowMedium
BundleTinyTinyMedium
Derived stateManual selectorsDerived atomsSelector graphs
AsyncManual + middlewareNative SuspenseNative Suspense
Provider requiredNoOptionalYes (RecoilRoot)

In 2026, Zustand is the pragmatic default for most apps. Jotai is the right call when your state naturally decomposes into independent atoms and you want Suspense integration. Recoil justifies its larger footprint when derived state dependencies become complex enough that manual memoization would be error-prone.

Pick based on your state’s shape, not hype.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.