React
|stacknotice.com
12 min left|
0%
|2,400 words
React

Zustand vs Jotai (2026): Store-Based vs Atomic State in React

Zustand centralizes state in stores with actions. Jotai scatters it into composable atoms. Both replace Redux without the boilerplate. Real code, real trade-offs.

C
Carlos Oliva
Software Developer
August 12, 202612 min read
Share:
Zustand vs Jotai (2026): Store-Based vs Atomic State in React

Both Zustand and Jotai came from the same creator (Daishi Kato) and solve the same problem: React state management without Redux's boilerplate, without React Context's re-render problem, and without the cognitive overhead of reducers and action types.

The difference is the mental model they impose.

Zustand thinks in stores — you define a slice of state with its actions in one object, and components subscribe to exactly what they need. Jotai thinks in atoms — small pieces of state that compose into larger derived values, similar to how spreadsheet cells reference each other. Both are ~3kb and work with the same React version.

Choosing between them is about how you think about your state: as a centralized store with named operations, or as a graph of derived values.

The Core Model

Zustand: One Store, Selectors for Performance

// store/useCartStore.ts
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
 
interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}
 
interface CartStore {
  items: CartItem[]
  isOpen: boolean
  // Actions live alongside state — no separate reducers
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: string) => void
  updateQuantity: (id: string, quantity: number) => void
  clearCart: () => void
  toggleCart: () => void
}
 
export const useCartStore = create<CartStore>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],
        isOpen: false,
 
        addItem: (item) => set((state) => {
          const existing = state.items.find(i => i.id === item.id)
          if (existing) {
            return {
              items: state.items.map(i =>
                i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
              )
            }
          }
          return { items: [...state.items, { ...item, quantity: 1 }] }
        }),
 
        removeItem: (id) => set(state => ({
          items: state.items.filter(i => i.id !== id)
        })),
 
        updateQuantity: (id, quantity) => set(state => ({
          items: quantity <= 0
            ? state.items.filter(i => i.id !== id)
            : state.items.map(i => i.id === id ? { ...i, quantity } : i)
        })),
 
        clearCart: () => set({ items: [] }),
        toggleCart: () => set(state => ({ isOpen: !state.isOpen }))
      }),
      { name: 'cart-storage' }  // persists to localStorage
    )
  )
)
// components/CartButton.tsx
export function CartButton() {
  // Selector — only re-renders when items.length changes
  const itemCount = useCartStore(state => state.items.reduce((sum, i) => sum + i.quantity, 0))
  const toggleCart = useCartStore(state => state.toggleCart)
 
  return (
    <button onClick={toggleCart}>
      Cart ({itemCount})
    </button>
  )
}
 
// components/CartTotal.tsx
export function CartTotal() {
  // Different selector — re-renders independently of CartButton
  const total = useCartStore(state =>
    state.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
  )
  return <span>${total.toFixed(2)}</span>
}

Selectors are the key to Zustand's performance model. A component that subscribes to state.items.length doesn't re-render when state.isOpen changes. Selectors are functions — any computation is valid.

Jotai: Atoms Compose Into Derived Values

// store/cartAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
 
interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}
 
// Primitive atoms — single source of truth
export const cartItemsAtom = atomWithStorage<CartItem[]>('cart-items', [])
export const cartOpenAtom = atom(false)
 
// Derived atoms — computed from other atoms, no duplication
export const cartCountAtom = atom(
  (get) => get(cartItemsAtom).reduce((sum, i) => sum + i.quantity, 0)
)
 
export const cartTotalAtom = atom(
  (get) => get(cartItemsAtom).reduce((sum, i) => sum + i.price * i.quantity, 0)
)
 
// Write atom — encapsulates the "add item" logic
export const addItemAtom = atom(
  null,  // no read value — write-only atom
  (get, set, item: Omit<CartItem, 'quantity'>) => {
    const items = get(cartItemsAtom)
    const existing = items.find(i => i.id === item.id)
    if (existing) {
      set(cartItemsAtom, items.map(i =>
        i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
      ))
    } else {
      set(cartItemsAtom, [...items, { ...item, quantity: 1 }])
    }
  }
)
 
export const removeItemAtom = atom(
  null,
  (get, set, id: string) => {
    set(cartItemsAtom, get(cartItemsAtom).filter(i => i.id !== id))
  }
)
// components/CartButton.tsx
import { useAtomValue, useSetAtom } from 'jotai'
 
export function CartButton() {
  // Only subscribes to count — re-renders only when count changes
  const itemCount = useAtomValue(cartCountAtom)
  const setOpen = useSetAtom(cartOpenAtom)
 
  return (
    <button onClick={() => setOpen(prev => !prev)}>
      Cart ({itemCount})
    </button>
  )
}
 
// components/CartTotal.tsx
export function CartTotal() {
  const total = useAtomValue(cartTotalAtom)
  return <span>${total.toFixed(2)}</span>
}
 
// components/AddToCart.tsx
export function AddToCart({ product }: { product: Product }) {
  const addItem = useSetAtom(addItemAtom)
  return (
    <button onClick={() => addItem({ id: product.id, name: product.name, price: product.price })}>
      Add to cart
    </button>
  )
}

Jotai's granularity is at the atom level. CartButton subscribes to cartCountAtom, CartTotal subscribes to cartTotalAtom — they're completely independent subscriptions derived from the same base atom.

Async State

This is where the models diverge most in practice.

Zustand: Actions Handle Async

interface UserStore {
  user: User | null
  loading: boolean
  error: string | null
  fetchUser: (id: string) => Promise<void>
  updateProfile: (data: Partial<User>) => Promise<void>
}
 
export const useUserStore = create<UserStore>()((set) => ({
  user: null,
  loading: false,
  error: null,
 
  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      const user = await api.users.getById(id)
      set({ user, loading: false })
    } catch (err) {
      set({ error: (err as Error).message, loading: false })
    }
  },
 
  updateProfile: async (data) => {
    set({ loading: true })
    try {
      const updated = await api.users.update(data)
      set({ user: updated, loading: false })
    } catch (err) {
      set({ error: (err as Error).message, loading: false })
    }
  }
}))
 
// Usage
function ProfilePage({ userId }: { userId: string }) {
  const { user, loading, error, fetchUser } = useUserStore()
 
  useEffect(() => {
    fetchUser(userId)
  }, [userId])
 
  if (loading) return <Spinner />
  if (error) return <ErrorMessage error={error} />
  return <Profile user={user!} />
}

Jotai: Async Atoms with Suspense

import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'  // or loadable from jotai/utils
 
// Async atom — the fetch IS the atom
export const userIdAtom = atom<string | null>(null)
 
export const userAtom = atom(async (get) => {
  const id = get(userIdAtom)
  if (!id) return null
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) throw new Error('Failed to fetch user')
  return response.json() as Promise<User>
})
 
// Or with TanStack Query integration — best of both worlds
export const userQueryAtom = atomWithQuery((get) => ({
  queryKey: ['user', get(userIdAtom)],
  queryFn: ({ queryKey: [, id] }) => api.users.getById(id as string),
  enabled: !!get(userIdAtom)
}))
 
// Usage with Suspense — loading/error handled at the boundary
function ProfilePage({ userId }: { userId: string }) {
  const setUserId = useSetAtom(userIdAtom)
 
  useEffect(() => {
    setUserId(userId)
  }, [userId])
 
  return (
    <Suspense fallback={<Spinner />}>
      <ErrorBoundary fallback={<ErrorMessage />}>
        <ProfileContent />
      </ErrorBoundary>
    </Suspense>
  )
}
 
function ProfileContent() {
  // Suspends until data is ready — no loading check needed
  const [{ data: user }] = useAtom(userQueryAtom)
  return <Profile user={user!} />
}

Jotai's async atoms integrate naturally with Suspense — the component suspends while the atom resolves, and the Suspense boundary handles the loading state. Zustand handles async manually in actions, which is more explicit but requires you to manage loading/error states yourself.

Derived State and Computed Values

Zustand: Computed via Selectors

// Computed values are selectors — calculated every render where used
const expensiveTotal = useCartStore(state =>
  state.items
    .filter(i => i.price > 100)
    .reduce((sum, i) => sum + i.price * i.quantity, 0)
)
 
// For expensive computations, memoize in the selector
import { useShallow } from 'zustand/react/shallow'
 
const { items, total } = useCartStore(useShallow(state => ({
  items: state.items,
  total: state.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
})))

Jotai: Derived Atoms Are Memoized by Default

// Derived atoms only recompute when their dependencies change
export const expensiveItemsAtom = atom((get) => {
  const items = get(cartItemsAtom)
  return items.filter(i => i.price > 100)
})
 
export const expensiveTotalAtom = atom((get) => {
  const items = get(expensiveItemsAtom)  // uses memoized result
  return items.reduce((sum, i) => sum + i.price * i.quantity, 0)
})
 
// Both atoms only recompute when cartItemsAtom changes
// Multiple components using expensiveTotalAtom share the same computation

Jotai's derived atoms are memoized automatically — the computation runs once when dependencies change, and all subscribers share the cached result. Zustand's selectors recompute on every render where the subscribed state changed (unless you memoize manually with useShallow or useMemo).

Middleware and Persistence

Zustand Middleware Stack

import { create } from 'zustand'
import { devtools, persist, subscribeWithSelector, immer } from 'zustand/middleware'
 
const useStore = create<State>()(
  devtools(          // Redux DevTools integration
    persist(         // localStorage/sessionStorage persistence
      subscribeWithSelector(  // subscribe to specific state slices
        immer(       // Immer for immutable updates with mutable syntax
          (set) => ({
            items: [] as CartItem[],
            addItem: (item: CartItem) => set(state => {
              state.items.push(item)  // Immer makes this safe
            })
          })
        )
      ),
      {
        name: 'store',
        partialize: (state) => ({ items: state.items })  // only persist items
      }
    )
  )
)

Jotai Utils

import { atomWithStorage, atomWithReset, RESET } from 'jotai/utils'
import { atomWithImmer } from 'jotai-immer'
 
// Persisted atom
export const themeAtom = atomWithStorage('theme', 'dark')
 
// Resettable atom
export const filterAtom = atomWithReset({ category: '', minPrice: 0 })
 
// Reset to initial value
const resetFilter = useSetAtom(filterAtom)
resetFilter(RESET)
 
// Immer atom for complex nested updates
export const settingsAtom = atomWithImmer({
  notifications: { email: true, push: false },
  privacy: { shareData: false }
})
 
// Usage — mutable syntax, Immer handles immutability
const updateSettings = useSetAtom(settingsAtom)
updateSettings(draft => {
  draft.notifications.push = true
})

When the Performance Model Matters

Both libraries are fast for typical React apps. The difference shows up in specific scenarios.

Zustand performs better when:

  • You have a large store and want fine-grained subscriptions via selectors
  • State is cross-cutting (multiple unrelated components share it)
  • You want predictable re-renders tied to explicit action calls

Jotai performs better when:

  • State is highly granular and local to component trees
  • You're using Suspense-based async patterns extensively
  • Derived values are expensive and benefit from automatic memoization

For a typical e-commerce app or dashboard, both will have identical performance characteristics. Profile first — don't optimize for a problem you haven't measured.

Decision Framework

SituationChoose
Coming from Redux, want familiar patternsZustand
State is shared across many unrelated componentsZustand
Complex actions with side effectsZustand
Redux DevTools integration mattersZustand
State is fine-grained and component-localJotai
Suspense-based async is your patternJotai
Need atomWithQuery (TanStack Query integration)Jotai
Derived values with automatic memoizationJotai
Coming from RecoilJotai

Honest assessment: For most React applications, Zustand is the lower-friction starting point. Its mental model is closer to how most developers think about state — a bag of values with operations that change them. Jotai's atomic model becomes genuinely better when state is highly reactive and granular, and when you're leaning into Suspense for async. If you're building a data-heavy dashboard with lots of derived views from the same data, Jotai's memoization and composability are a real advantage.

For a comprehensive look at Zustand including slices, subscriptions, and persist patterns, see the Zustand complete guide. For the Context comparison and why both beat useContext for shared state, see React Context vs Zustand. Both libraries work alongside TanStack Query — server state in TanStack Query, client state in Zustand or Jotai is the combination that avoids re-render issues entirely.

#react#zustand#typescript#state-management
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.