State Management
Local vs lifted vs global state, when Context suffices, and Redux Toolkit, Zustand, Jotai/Recoil.
The state ladder#
Reach for the least powerful tool that works. Most apps need far less global state than teams assume — much of what looks "global" is actually server state (cache of remote data) that belongs in React Query/SWR, not a store.
| Level | Tool | Use when |
|---|---|---|
| Local | useState/useReducer |
State used by one component/subtree |
| Lifted | Move state to closest common parent | A few siblings share it |
| Context | useContext + provider |
Low-frequency global-ish values (theme, auth, locale) |
| Store lib | Redux Toolkit / Zustand / Jotai | Large, frequently-updated, cross-cutting client state |
| Server cache | React Query / SWR / RTK Query | Any data fetched from a server |
flowchart TD
A["Need to share state?"] -->|No| B["useState / useReducer (local)"]
A -->|Few siblings| C["Lift to common parent"]
A -->|Many, low-frequency| D["Context"]
A -->|Many, high-frequency| E["Store: Redux Toolkit / Zustand / Jotai"]
A -->|It's remote data| F["React Query / SWR"]
When Context is enough vs a library#
Context is dependency injection, not a state manager. Any consumer re-renders when the provider value changes — so it's great for stable, rarely-changing values and poor for high-frequency updates.
⚠️ Putting a fast-changing value in one big context re-renders every consumer. Fixes: split into multiple contexts, memoize the value, or move to a store with selectors so components subscribe to slices.
🟢 Rule of thumb: Context for theme/auth/i18n; a store when you have complex updates, many subscribers, middleware, or need selector-based subscriptions.
Redux Toolkit (RTK)#
The official, modern Redux. Kills boilerplate: createSlice generates action creators + reducers, uses Immer so you "mutate" drafts safely, and configures the store with good defaults (thunk, devtools).
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { Provider, useSelector, useDispatch } from "react-redux";
const counter = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (s) => { s.value += 1; }, // Immer: safe "mutation"
addBy: (s, action) => { s.value += action.payload; },
},
});
export const { increment, addBy } = counter.actions;
const store = configureStore({ reducer: { counter: counter.reducer } });
function Counter() {
const value = useSelector((s) => s.counter.value); // subscribe to a slice
const dispatch = useDispatch();
return <button onClick={() => dispatch(addBy(5))}>{value}</button>;
}
function Root() { return <Provider store={store}><Counter /></Provider>; }
🎯 RTK's createAsyncThunk handles async flows; RTK Query is its built-in data-fetching/caching layer (an alternative to React Query when you're already on Redux).
Zustand — minimal store#
A tiny hook-based store: no provider, no boilerplate. The hook is the store; pass a selector to subscribe to just what you need.
import { create } from "zustand";
const useBearStore = create((set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
reset: () => set({ bears: 0 }),
}));
function Bears() {
const bears = useBearStore((s) => s.bears); // selector = targeted subscription
const addBear = useBearStore((s) => s.addBear);
return <button onClick={addBear}>{bears}</button>;
}
🟢 No <Provider> needed and selectors give you fine-grained re-renders out of the box — why Zustand is popular for medium apps.
Jotai & Recoil (atoms, brief)#
Atomic state: state is composed of small atoms; components subscribe to individual atoms and only re-render when those change. Derived atoms compute from others (like Recoil selectors).
import { atom, useAtom } from "jotai";
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2); // derived
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubled] = useAtom(doubledAtom);
return <button onClick={() => setCount((c) => c + 1)}>{count} / {doubled}</button>;
}
⚠️ Recoil is largely unmaintained/archived — prefer Jotai for the atomic model in new code.
Comparison & when to pick#
| Option | Boilerplate | Provider | Selectors | Best for |
|---|---|---|---|---|
| Context | Low | Yes | No (whole value) | Theme, auth, low-frequency globals |
| Redux Toolkit | Medium | Yes | Yes | Large apps, middleware, devtools, team conventions |
| Zustand | Very low | No | Yes | Medium apps wanting simplicity + fine-grained updates |
| Jotai | Low | No (optional) | Atom-level | Bottom-up atomic/derived state |
| React Query | Low | Yes | Query keys | Server state: caching, refetch, mutations |
🎯 The senior answer: separate server state from client state. Cache remote data with React Query; keep only genuine client/UI state in Redux/Zustand/Context. Don't dump fetched data into Redux.
Interview Q&A#
Q1. When is Context not enough? When the value updates frequently or has many consumers — every consumer re-renders on any change. Stores provide selector-based subscriptions so components only re-render for the slice they use.
Q2. Why Redux Toolkit over classic Redux?
It removes boilerplate (createSlice auto-generates actions/reducers), bundles Immer for safe "mutations", and ships sane defaults (thunk, devtools, immutability checks). Hand-written action types/switch reducers are legacy.
Q3. Redux vs React Query — competitors? No. Redux manages client state; React Query manages server state (caching, background refetch, staleness, mutations). Using React Query removes most of what people previously stored in Redux.
Q4. Zustand vs Redux Toolkit? Zustand is provider-less with near-zero boilerplate and built-in selectors — great for small/medium apps. RTK offers structured conventions, middleware, and rich devtools that scale better on large teams.
Q5. What is atomic state (Jotai/Recoil)? State split into small atoms; components subscribe to individual atoms and re-render only when those change. Derived atoms compute from others. It's a bottom-up alternative to a single central store.