Choosing the right state management approach is less about "which library is best" and more about understanding the nature of your state. Different types of state have different requirements for caching, synchronization, and reactivity.
Before picking a tool, classify each piece of state in your app:
State that exists only in the component tree and disappears on unmount.
Examples: modal open/closed, form input values, hover state, scroll position, which accordion is expanded
Right tool: useState, useReducer
Wrong tool: Any global state library. If you're putting isModalOpen in Zustand, you've gone too far.
State that's shared across components but doesn't come from a server. It's "owned" by the client.
Examples: theme preference, sidebar collapsed, selected items, shopping cart, notification queue, UI filters
Right tool: Zustand or Jotai
| If your client state is... | Use |
|---|---|
| One big interconnected object (dashboard state, complex forms) | Zustand — one store, slice pattern |
| Many independent pieces (theme, locale, sidebar, each widget's state) | Jotai — one atom per concern |
| Need cross-component actions (add to cart from any component) | Zustand — actions live in the store |
| Need derived values that depend on multiple inputs | Jotai — derived atoms are automatic |
| Need persistence (localStorage, IndexedDB) | Either — both have persistence middleware |
| Need undo/redo | Zustand — snapshot-based undo is simpler with a single store |
State that's a snapshot of data living on a server. Needs fetching, caching, background updates, and invalidation.
Examples: user profile, project list, API responses, search results, paginated data
Right tool: React Query (TanStack Query)
Wrong tool: Zustand/Jotai for server data. You'll end up rebuilding caching, stale detection, and background refetching — which React Query already does.
State that should be reflected in the URL so it's shareable and bookmarkable.
Examples: current page/route, search query, filter selections, sort order, active tab
Right tool: URL search params (useSearchParams), your router's state
The current values, validation errors, and submission state of a form.
Right tool: React Hook Form, or useState for simple forms
Is the state shared across multiple components?
├── No → useState / useReducer
└── Yes →
Does it come from a server/API?
├── Yes → React Query
│ ├── Need caching + background refetch? → React Query (queries)
│ ├── Need optimistic updates? → React Query (mutations)
│ └── Need infinite scroll? → React Query (infinite queries)
└── No (client-owned state) →
Is it a single cohesive domain (one store)?
├── Yes → Zustand
│ ├── Need devtools? → Zustand + devtools middleware
│ ├── Need persistence? → Zustand + persist middleware
│ └── Need immer-style mutations? → Zustand + immer middleware
└── No (many independent pieces) → Jotai
├── Need localStorage sync? → atomWithStorage
├── Need derived/computed values? → derived atoms
└── Need async data in atoms? → async atoms (consider React Query)
Most real apps use 2-3 of these together. They're complementary, not competitive.
| Layer | Tool | Handles |
|---|---|---|
| Server state | React Query | API data, caching, mutations |
| Client state | Zustand OR Jotai | Theme, UI prefs, shopping cart |
| Local state | useState | Form inputs, toggles, ephemeral UI |
| URL state | Router (Next.js, React Router) | Navigation, shareable filters |
React Query + Zustand: Use React Query for data fetching. When a mutation succeeds, update Zustand state if needed (e.g., add a notification to the Zustand notification queue).
React Query + Jotai: Store the query key parameters in Jotai atoms. When atoms change, React Query automatically refetches (because the query key changes).
Zustand + Jotai: Unusual combination. Pick one for client state. If you must use both, use Zustand for "store-shaped" state (auth, cart) and Jotai for scattered UI atoms.
| Concern | Zustand | Jotai | React Query |
|---|---|---|---|
| Re-render scope | Selector-based (you control it) | Atom-based (automatic) | Query-key based (automatic) |
| Bundle size | ~1.5 KB | ~2 KB | ~12 KB |
| DevTools | Yes (Redux DevTools) | Yes (Jotai DevTools) | Yes (TanStack Query DevTools) |
| SSR support | Yes | Yes | Yes (with hydration) |
| React 18 concurrent | Yes | Yes (Suspense-first) | Yes |
| Learning curve | Low | Medium | Medium |
Don't create a Zustand store with user, products, cart, notifications, theme, and settings. Split by domain or use Jotai.
// BAD: Zustand doing server state management
const useStore = create((set) => ({
users: [],
fetchUsers: async () => {
const data = await fetch('/api/users');
set({ users: await data.json() });
},
}));
// GOOD: Let React Query handle it
const useUsers = () => useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});If you fetch a user list with React Query and then copy it into Zustand, you now have two sources of truth. They WILL get out of sync.
Not every boolean needs to be an atom. If a piece of state is only used in one component, useState is fine.
Don't manually setQueryData everywhere. Prefer invalidateQueries unless you have a specific reason (optimistic updates, cache seeding).
1. Each Redux slice becomes a Zustand slice (see store-slices.ts)
2. useSelector(selectThing) becomes useStore(s => s.thing)
3. dispatch(action()) becomes useStore(s => s.action)()
4. Middleware maps 1:1 (devtools, persist, immer)
1. Each context value becomes an atom
2. Derived context values become derived atoms
3. Remove Provider components — atoms are global
4. useContext(ThemeContext) becomes useAtomValue(themeAtom)
1. useSWR(key, fetcher) becomes useQuery({ queryKey: [key], queryFn: fetcher })
2. mutate() becomes invalidateQueries() or setQueryData()
3. Global config moves to QueryClient defaults
4. Add mutation hooks for writes (SWR handles these less elegantly)
| If you need... | Reach for... |
|---|---|
| Simple component state | useState |
| Complex component state | useReducer |
| Global UI state (one cohesive store) | Zustand |
| Global UI state (many independent atoms) | Jotai |
| Server data (fetch, cache, sync) | React Query |
| Form state | React Hook Form |
| URL state | Router params |
| Real-time sync | WebSocket + React Query invalidation |
The goal isn't to find the "one true state management solution." It's to use the right tool for each type of state, keeping each layer simple and focused.
Part of Frontend Developer Pro