Frontend / State managers / choosing_a_state_library.md

Choosing a State Library

Updated 4 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

Choosing a State Library

TL;DR

First split your state: server state → a query library (TanStack Query / RTK Query), always. Then for the remaining client state, start as local as possible (useState) and lift only when sharing demands it. Reach for a global client store (Zustand/Jotai/Redux) when state is shared across distant components. Pick Redux/RTK specifically when client state is large and complex, the team needs enforced structure, or you want time-travel devtools and a rich middleware ecosystem — otherwise its boilerplate is a cost without payoff.

In depth

What’s your decision framework?

  1. Is it server data? → TanStack Query or RTK Query. Stop. (Server State vs Client State)
  2. Is it used by one component / a small subtree?useState/useReducer, lift to the nearest common parent if shared.
  3. Shared across distant parts of the tree, low frequency? → Context (with care) or a small store.
  4. Shared widely, frequent updates, or complex? → Zustand/Jotai for light needs; Redux/RTK when you need structure and tooling.

The mistake is starting at step 4.

Compare the main options.

Tool Model Boilerplate Best for
useState/useReducer local none component-local state
Context top-down provide/consume low low-frequency global (theme, auth, locale)
Zustand single store low shared client state, minimal ceremony
Jotai atomic, bottom-up low many small/derived pieces
Valtio / MobX proxy/observable low mutable mental model, fine-grained reactivity
Redux Toolkit single store + conventions medium large/complex client state, big teams, devtools
TanStack / RTK Query server cache low server state (not client state)

When is Redux the wrong choice?

When the app is small, the state is mostly server data, or the team wants minimal indirection. Symptoms of misuse: reducers full of isLoading/error flags (that’s server state — use a query lib), or a global store holding values only one component reads (that’s local state). “We use Redux because we always use Redux” isn’t a reason.

What is state colocation and why does it matter?

Keep state as close to where it’s used as possible; only lift it when something else genuinely needs it. Colocated state is easier to reason about, re-renders a smaller subtree, and deletes cleanly with the component. Premature globalization creates coupling and unnecessary re-renders. The progression is: local → lifted → context/store, moved only when forced.

The cost of getting it wrong is concrete — a value in context re-renders every consumer, whether or not it read the part that changed:

tsx
// One provider holding unrelated things: a theme flip
// re-renders every component that reads `user`.
<AppContext.Provider value={{ user, theme, sidebarOpen }}>

// Split by change frequency instead.
<UserContext.Provider value={user}>
  <ThemeContext.Provider value={theme}>

A store with selectors avoids this by construction, which is the actual argument for reaching past context:

ts
// Only re-renders when `theme` changes, not on any store write.
const theme = useStore((s) => s.theme)

How do you handle relational/normalized client state in Redux?

createEntityAdapter stores entities as { ids: [], entities: {} } (normalized, O(1) lookup) and generates CRUD reducers + memoized selectors (selectAll, selectById):

ts
const adapter = createEntityAdapter<Todo>();
const slice = createSlice({
  name: "todos",
  initialState: adapter.getInitialState(),
  reducers: { addTodo: adapter.addOne, updateTodo: adapter.updateOne, removeTodo: adapter.removeOne },
});
export const { selectAll, selectById } = adapter.getSelectors((s) => s.todos);

Normalization avoids duplicated nested data and the bugs that come from updating it in two places.

Thunks, sagas, or listener middleware for side effects?

  • Thunks (built into RTK) — the default for simple async; just async functions dispatching actions.
  • Listener middleware (createListenerMiddleware) — the modern reactive option: run logic in response to dispatched actions or state changes, with condition/takeLatest-style control, without saga’s generator overhead. The recommended replacement for most saga use cases.
  • Sagas — generator-based, powerful for complex orchestration/cancellation, but heavier; reach for them only when listener middleware isn’t enough.

Where do Vue’s options fit?

Pinia is Vue’s official store (the Zustand-simplicity-with-Vue-reactivity option); Vuex is its legacy predecessor. The same server-vs-client split applies in Vue. See Pinia (and Where Vuex Still Appears).

Gotchas / edge cases

  • Don’t put server data in a client store — the most common architectural mistake; it reimplements caching badly.
  • Context is not a state manager — it’s dependency injection; every consumer re-renders on value change. Split contexts or use a store for frequently-changing values (Context Performance Traps).
  • One global store for everything couples unrelated features and bloats re-renders; colocate.
  • Migrations cost — picking a library is somewhat sticky; the cheapest first move (TanStack Query for server state) often removes 70% of perceived “state management” need before you choose a client store at all.

What a senior is expected to say 4

  • “Split server vs client state first. Server → query library. Client → start local, lift only when shared, reach for a store when shared widely.”
  • “Redux earns its boilerplate on large, complex client state with a big team and devtools needs; it’s the wrong tool for a small app or mostly-server-data app.”
  • “Colocate state; Context is DI, not a performant store for hot values.”
  • “For relational client data I normalize with createEntityAdapter; for reactive side effects I prefer listener middleware over sagas.”

Cross-references

Further reading