Choosing a State Library
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.
What’s your decision framework?
- Is it server data? → TanStack Query or RTK Query. Stop. (Server State vs Client State)
- Is it used by one component / a small subtree? →
useState/useReducer, lift to the nearest common parent if shared. - Shared across distant parts of the tree, low frequency? → Context (with care) or a small store.
- 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 | Best for |
|---|---|---|
useState/useReducer |
local | component-local state |
| Context | top-down provide/consume | low-frequency global (theme, auth, locale) |
| Zustand | single store | shared client state, minimal ceremony |
| Jotai | atomic, bottom-up | many small/derived pieces |
| Valtio / MobX | proxy/observable | mutable mental model, fine-grained reactivity |
| Redux Toolkit | single store + conventions | large client state, big teams, devtools |
| TanStack/RTK Query | server cache | server state (not client state) |
Boilerplate is low for all of them except Redux Toolkit (medium), and none for the built-in hooks.
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:
// 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:
// 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):
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, withcondition/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
- 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.
Related
Further reading
- Redux — “Redux Essentials” and when to use Redux: https://redux.js.org/faq/general#when-should-i-use-redux
- Kent C. Dodds — “State Colocation will make your React app faster”: https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster
- RTK —
createListenerMiddleware: https://redux-toolkit.js.org/api/createListenerMiddleware
Interview angle 4
- “How do you choose a state library?” - split server state from client
state first, because that decision makes most of the rest obvious. Server data
goes to a query library. Client state starts in
useState, gets lifted only when it is genuinely shared, and only reaches a store when it is shared widely.- Follow-up: “When does Redux earn its boilerplate?” - large, complex client state, a big team that benefits from one enforced pattern, and real use of time-travel devtools. On a small app, or one that is mostly server data wearing a store, it is overhead with no return.
- “Where does Context fit?” - as dependency injection, not as a store. It has no selector, so every consumer re-renders when the value reference changes. Theme, locale and an API client are the right shape; a hot value many components read is not.
- “How do you handle relational client data?” - normalise it, with something
like
createEntityAdapter, so an entity lives in one place and lists hold ids. Duplicating the same object across three slices is how you get two views disagreeing about the same record. - “Sagas or listener middleware for side effects?” - listener middleware for almost everything. Sagas bring a generator-based vocabulary that is powerful and expensive to onboard, and most applications need “when this action fires, do that”, which the listener API expresses directly.