State Management Libraries
Compare Zustand, Jotai, and Context — when to use each
React built-ins (useState, Context) cover many cases. Libraries add perf, ergonomics, and middleware.
What you'll learn
Create Zustand stores with selectors and actions
Build Jotai atoms with derived state
Compare Context vs Zustand vs Jotai vs Redux tradeoffs
Use localStorage persistence middleware
Integrate devtools for debugging
Choose the right tool for different app scales
Zustand Basics
Minimal API. Create a store with a function, get hooks back.
import { create } from "zustand"
type CounterStore = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
const useCounter = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
function Counter() {
const count = useCounter((state) => state.count)
const { increment, decrement } = useCounter()
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}
Selectors for Perf
Zustand only re-renders when selected value changes:
const userName = useUserStore((state) => state.user.name)
// Component only re-renders if user.name changes, not whole store
Jotai Atoms
Atomic state — each atom is a standalone unit. Compose them.
import { atom, useAtom } from "jotai"
const countAtom = atom(0)
const doubledAtom = atom((get) => get(countAtom) * 2)
const incrementAtom = atom(null, (get, set) =>
set(countAtom, get(countAtom) + 1)
)
function Counter() {
const [count] = useAtom(countAtom)
const [doubled] = useAtom(doubledAtom)
const [, increment] = useAtom(incrementAtom)
return (
<button onClick={increment}>
{count} (doubled: {doubled})
</button>
)
}
Derived Atoms
const todosAtom = atom<Todo[]>([])
const completedAtom = atom((get) => get(todosAtom).filter((t) => t.completed))
const statsAtom = atom((get) => {
const all = get(todosAtom)
return { total: all.length, done: get(completedAtom).length }
})
Comparison
| Feature | Context | Zustand | Jotai | Redux |
|---|---|---|---|---|
| Bundle size | 0 | ~1KB | ~3KB | ~11KB |
| Re-renders | All consumers | By selector | By atom | By selector |
| Boilerplate | None | Minimal | Minimal | Actions/reducers |
| Middleware | No | Yes (persist, devtools) | Yes | Yes (redux-saga, thunk) |
| SSR friendly | Yes | Yes | Yes | Yes |
| Async | Manual | Built-in | Built-in | Thunks/sagas |
Reaching for a Library
Use Context when:
- State changes infrequently (theme, locale, auth)
- Small subtree of consumers
- No need for devtools/persistence
Use Zustand when:
- Frequent updates across many components
- Need middleware (persist, devtools)
- Want actions outside React
Use Jotai when:
- Highly granular state (form fields, UI toggles)
- Composable derived state
- Each value has different update frequency
Over-engineering Trap
If two components share state, start with lifted useState. Only reach for a library when lifting becomes painful or perf degrades. Don't put server data in global state — use TanStack Query or SWR.
Persisting Store State
Zustand persist middleware saves to localStorage:
import { create } from "zustand"
import { persist } from "zustand/middleware"
type ThemeStore = {
theme: "light" | "dark"
setTheme: (theme: "light" | "dark") => void
}
const useTheme = create<ThemeStore>()(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{ name: "theme-preference" }
)
)
// Theme survives page reloads
Devtools Integration
import { devtools } from "zustand/middleware"
const useStore = create<Store>()(
devtools(
(set) => ({
user: null,
login: (user) => set({ user }, false, "auth/login"),
logout: () => set({ user: null }, false, "auth/logout"),
}),
{ name: "AppStore" }
)
)
// Visible in Redux DevTools browser extension
Challenge
Build a shopping cart store with Zustand. Must support addItem, removeItem, updateQuantity, and compute total.
Shopping Cart with Zustand
Code
tsx
1import { create } from "zustand"2 3// Step 1: Define CartItem type (id, name, price, quantity)4// Step 2: Define CartStore type (items, addItem, removeItem, updateQuantity, total)5// Step 3: Create store with create<CartStore>()6// Step 4: total should be a getter — computed from items7 8export function Cart() {9// Select items and total from store10return (11 <div>12 <h2>Cart</h2>13 <ul>{/* Map items */}</ul>14 <p>Total: $0.00</p>15 <button>Add Item</button>16 </div>17)18}Key Takeaways
Zustand: minimal store with selectors for targeted re-renders
Jotai: composable atoms for fine-grained state
Context re-renders all consumers — use for infrequent changes only
persist middleware saves/loads from localStorage automatically
devtools middleware enables Redux DevTools debugging
Start simple (useState/Context); reach for a library when it hurts
Server state belongs in TanStack Query/SWR, not global store