Contents

Chapter 1

State Management Decision Guide

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.

The State Classification Framework

Before picking a tool, classify each piece of state in your app:

1. UI State (Local, Ephemeral)

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.

2. Client State (Global, Synchronous)

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 inputsJotai — derived atoms are automatic
Need persistence (localStorage, IndexedDB)Either — both have persistence middleware
Need undo/redoZustand — snapshot-based undo is simpler with a single store

3. Server State (Remote, Async, Cached)

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.

4. URL State

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

5. Form State

The current values, validation errors, and submission state of a form.

Right tool: React Hook Form, or useState for simple forms

Decision Tree

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)

Combining Libraries

Most real apps use 2-3 of these together. They're complementary, not competitive.

The Common Stack

LayerToolHandles
Server stateReact QueryAPI data, caching, mutations
Client stateZustand OR JotaiTheme, UI prefs, shopping cart
Local stateuseStateForm inputs, toggles, ephemeral UI
URL stateRouter (Next.js, React Router)Navigation, shareable filters

Integration Patterns

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.

Performance Comparison

ConcernZustandJotaiReact Query
Re-render scopeSelector-based (you control it)Atom-based (automatic)Query-key based (automatic)
Bundle size~1.5 KB~2 KB~12 KB
DevToolsYes (Redux DevTools)Yes (Jotai DevTools)Yes (TanStack Query DevTools)
SSR supportYesYesYes (with hydration)
React 18 concurrentYesYes (Suspense-first)Yes
Learning curveLowMediumMedium

Anti-Patterns to Avoid

1. Putting Everything in One Store

Don't create a Zustand store with user, products, cart, notifications, theme, and settings. Split by domain or use Jotai.

2. Fetching Data in Zustand Actions

typescript
// 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()),
});

3. Duplicating Server Data in Client State

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.

4. Over-Atomizing with Jotai

Not every boolean needs to be an atom. If a piece of state is only used in one component, useState is fine.

5. Manual Cache Management with React Query

Don't manually setQueryData everywhere. Prefer invalidateQueries unless you have a specific reason (optimistic updates, cache seeding).

Migration Guide

From Redux to Zustand

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)

From Context + useReducer to Jotai

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)

From SWR to React Query

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)

Summary

If you need...Reach for...
Simple component stateuseState
Complex component stateuseReducer
Global UI state (one cohesive store)Zustand
Global UI state (many independent atoms)Jotai
Server data (fetch, cache, sync)React Query
Form stateReact Hook Form
URL stateRouter params
Real-time syncWebSocket + 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

State Management Patterns v1.0.0 — Free Preview