Web Development · Frontend
Zustand and Jotai: React State Management Without the Redux Ceremony
Redux is overkill for most React apps. Zustand and Jotai cover 90% of real state management needs with a fraction of the boilerplate. Here's when to use each and how they actually work.
Anurag Verma
7 min read
Sponsored
The React state management conversation used to be mostly about Redux. That’s changed. Most new projects don’t use Redux at all. Two smaller libraries have taken over the middle ground: Zustand for shared application state, and Jotai for granular atomic state that needs to cross component boundaries.
Both install in a single package. Neither requires a Provider wrapping your app. Neither generates boilerplate files. Both have TypeScript support that works without configuration.
Zustand
Zustand is a store library built around a hook. You define state and the functions that modify it in one place, then call that hook from any component that needs access.
import { create } from 'zustand'
interface CartStore {
items: CartItem[]
total: number
addItem: (item: CartItem) => void
removeItem: (id: string) => void
clearCart: () => void
}
export const useCartStore = create<CartStore>((set) => ({
items: [],
total: 0,
addItem: (item) =>
set((state) => {
const items = [...state.items, item]
return {
items,
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}
}),
removeItem: (id) =>
set((state) => {
const items = state.items.filter((i) => i.id !== id)
return {
items,
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}
}),
clearCart: () => set({ items: [], total: 0 }),
}))
Using the store from two components in different parts of the tree:
function CartBadge() {
// Selector: this component only re-renders when items.length changes
const count = useCartStore((state) => state.items.length)
return <span className="badge">{count}</span>
}
function CheckoutBar() {
const { total, clearCart } = useCartStore()
return (
<div>
<span>Total: ${total.toFixed(2)}</span>
<button onClick={clearCart}>Clear</button>
</div>
)
}
The selector pattern is the key detail. When you write useCartStore((state) => state.items.length), the component only re-renders when that specific value changes. Without the selector, the component re-renders on any store update. For a cart badge that only needs the count, that’s a meaningful difference.
Middleware
Zustand’s middleware system handles patterns that come up constantly:
Persist to localStorage:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const usePreferencesStore = create<PreferencesStore>()(
persist(
(set) => ({
theme: 'light' as 'light' | 'dark',
density: 'comfortable' as 'compact' | 'comfortable',
setTheme: (theme) => set({ theme }),
setDensity: (density) => set({ density }),
}),
{
name: 'user-preferences',
// Only persist these fields; skip transient state
partialize: (state) => ({ theme: state.theme, density: state.density }),
}
)
)
Connect to Redux DevTools:
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
count: 0,
// Third argument to set is the action name shown in DevTools
increment: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
decrement: () => set((s) => ({ count: s.count - 1 }), false, 'decrement'),
}),
{ name: 'MyStore' }
)
)
Splitting a Large Store into Slices
A single store with 30 fields becomes hard to read. Slices let you split by domain without losing the unified store:
// stores/auth-slice.ts
export const createAuthSlice = (set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
})
// stores/ui-slice.ts
export const createUiSlice = (set) => ({
sidebarOpen: false,
toasts: [],
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
addToast: (toast) => set((s) => ({ toasts: [...s.toasts, toast] })),
})
// stores/index.ts
type BoundStore = AuthSlice & UiSlice
const useBoundStore = create<BoundStore>()((...args) => ({
...createAuthSlice(...args),
...createUiSlice(...args),
}))
export const useAuth = () => useBoundStore((s) => ({ user: s.user, login: s.login, logout: s.logout }))
export const useUi = () => useBoundStore((s) => ({ sidebarOpen: s.sidebarOpen, toggleSidebar: s.toggleSidebar }))
The exports at the bottom create domain-scoped hooks that components import. Components never need to know about the combined store shape.
Jotai
Jotai takes the opposite approach. Instead of a single store, you have atoms: small, independent units of state that live at the module level and can be shared across components. The mental model is closer to useState, but with sharing built in.
import { atom } from 'jotai'
// Primitive atoms
export const searchQueryAtom = atom('')
export const pageAtom = atom(1)
export const selectedItemIdAtom = atom<string | null>(null)
// Derived atom (read-only, computed from others)
export const searchParamsAtom = atom((get) => ({
q: get(searchQueryAtom),
page: get(pageAtom),
}))
Using atoms in components:
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
function SearchInput() {
const [query, setQuery] = useAtom(searchQueryAtom)
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value)
// Reset page when query changes
}}
/>
)
}
function PageDisplay() {
// useAtomValue: subscribe to read, no setter needed
const page = useAtomValue(pageAtom)
return <span>Page {page}</span>
}
function PaginationControls() {
// useSetAtom: only the setter, no subscription (no re-render on value change)
const setPage = useSetAtom(pageAtom)
return (
<div>
<button onClick={() => setPage((p) => p - 1)}>Prev</button>
<button onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
)
}
The useSetAtom pattern is worth noting. A component that only needs to trigger state changes shouldn’t re-render when the value changes. useSetAtom subscribes to the setter only.
Async Atoms with Suspense
Jotai handles asynchronous derived state through a clean Suspense integration:
import { atom } from 'jotai'
import { useAtomValue } from 'jotai'
const selectedUserIdAtom = atom<string | null>(null)
const userDataAtom = atom(async (get) => {
const id = get(selectedUserIdAtom)
if (!id) return null
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed to load user')
return res.json() as Promise<User>
})
// This component automatically suspends while userDataAtom is loading
function UserDetail() {
const user = useAtomValue(userDataAtom)
if (!user) return <p>Select a user</p>
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
// Wrap in Suspense in the parent
function UserSection() {
return (
<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<ErrorState />}>
<UserDetail />
</ErrorBoundary>
</Suspense>
)
}
When selectedUserIdAtom changes, userDataAtom re-fetches and the component suspends again during the fetch. This is reactive data fetching without any extra library.
Writable Derived Atoms
Atoms can define both a read and a write function, letting you present a transformed view of underlying state:
const rawDateAtom = atom<Date>(new Date())
const dateStringAtom = atom(
(get) => get(rawDateAtom).toISOString().slice(0, 10),
(get, set, value: string) => {
set(rawDateAtom, new Date(value))
}
)
function DatePicker() {
const [dateStr, setDateStr] = useAtom(dateStringAtom)
return (
<input
type="date"
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
/>
)
}
The component works with a string. The atom handles conversion. The rest of your code works with a Date object.
When to Use Which
| Situation | Better choice |
|---|---|
| Global UI state (sidebar, modals, toasts) | Zustand |
| Auth session, user preferences | Zustand with persist middleware |
| Complex state with related actions | Zustand slices |
| Shared state between sibling components | Either |
| State derived from other state | Jotai |
| Async data that follows selected IDs or filters | Jotai async atoms |
| Fine-grained subscriptions to avoid re-renders | Jotai |
| Need Redux DevTools time-travel debugging | Zustand with devtools middleware |
The libraries aren’t mutually exclusive. A common pattern: Zustand handles the auth session, cart, and major UI flags. Jotai handles per-feature atoms like “which table row is selected” or “what is the search query on this page.” Neither competes with TanStack Query for server state (paginated API responses, mutations, caching).
What Redux Still Does Better
Redux Toolkit is still the answer if your team needs:
- Strict action logging for audit or compliance requirements
- Time-travel debugging as a primary debugging workflow
- Tight integration with existing Redux middleware (redux-saga, redux-observable)
- A pattern that junior developers can look up and find extensive documentation on
Those use cases exist. They’re just not the starting assumption for a new product in 2026.
For most new projects: start with TanStack Query for remote data, Zustand for shared client state, and reach for Jotai when you need fine-grained atoms or Suspense-integrated async state. You probably won’t need all three. Start with one and add the second only when the first doesn’t fit.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored