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 computationJotai'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
| Situation | Choose |
|---|---|
| Coming from Redux, want familiar patterns | Zustand |
| State is shared across many unrelated components | Zustand |
| Complex actions with side effects | Zustand |
| Redux DevTools integration matters | Zustand |
| State is fine-grained and component-local | Jotai |
| Suspense-based async is your pattern | Jotai |
Need atomWithQuery (TanStack Query integration) | Jotai |
| Derived values with automatic memoization | Jotai |
| Coming from Recoil | Jotai |
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.