State Management
Local vs lifted vs global state, when Context suffices, and Redux Toolkit, Zustand, Jotai/Recoil.
🧑🏫 Sabse pehle — simple mein samjho#
Flipkart pe tumhara cart aur logged-in user ka naam — ye data poori app mein kai jagah chahiye: header mein, cart page pe, checkout pe. Ise har component ko alag-alag props se pass karna (har level pe neeche bhejna, isko "prop drilling" kehte hain) bahut tang karta hai. Solution: aisa data ek jagah rakho jahan se koi bhi component seedha le le. Chhota kaam (theme, logged-in user) → React ka Context kaafi hai; bada ya complex app (cart, orders, filters sab ek saath) → Redux Toolkit ya Zustand jaisi library behtar.
const CartContext = createContext();
function App() {
const [cart, setCart] = useState([]);
// ab koi bhi component bina prop drilling ke cart le sakta hai
return (
<CartContext.Provider value={{ cart, setCart }}>
<Header />
</CartContext.Provider>
);
}
Yaad rakho: app-wide data ek jagah rakho — chhota kaam Context se, bada kaam Redux Toolkit/Zustand se; prop drilling se bacho.
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.
Worked example: cart state across Header, Cart page, and Checkout (Flipkart-style)#
The pain — prop drilling. cart lives in App, but Header (which just needs a count badge), CartPage, and Checkout all need it. Passing it down manually means every layout component in between takes cart/setCart props it never actually uses itself.
function App() {
const [cart, setCart] = useState([]);
return (
<Layout cart={cart} setCart={setCart}> {/* Layout doesn't use cart... */}
<Header cart={cart} /> {/* ...just forwards it */}
<Routes>
<Route path="/cart" element={<CartPage cart={cart} setCart={setCart} />} />
<Route path="/checkout" element={<Checkout cart={cart} />} />
</Routes>
</Layout>
);
}
The fix — Context. Cart updates are relatively infrequent (add/remove item) and read in only a handful of places — a textbook case for Context instead of a full store.
const CartContext = createContext(null);
function CartProvider({ children }) {
const [cart, setCart] = useState([]);
const addItem = (item) => setCart((c) => [...c, item]);
const removeItem = (id) => setCart((c) => c.filter((i) => i.id !== id));
return (
<CartContext.Provider value={{ cart, addItem, removeItem }}>
{children}
</CartContext.Provider>
);
}
function useCart() {
return useContext(CartContext);
}
// Header: only needs the count, no props passed down from App
function HeaderCartBadge() {
const { cart } = useCart();
return <span className="badge">{cart.length}</span>;
}
// Cart page: reads and mutates
function CartPage() {
const { cart, removeItem } = useCart();
return (
<ul>
{cart.map((item) => (
<li key={item.id}>
{item.name} <button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
// Checkout: just reads
function Checkout() {
const { cart } = useCart();
return <p>Paying for {cart.length} items</p>;
}
When Context stops being enough. If the cart grows into many independent global slices (cart, wishlist, filters, recently-viewed, notifications), updates come from many unrelated places at high frequency, or you need middleware/time-travel devtools for debugging — that's the signal to move to Redux Toolkit or Zustand instead of stretching Context further.
flowchart TD
subgraph L["Local state"]
L1["cart lives inside Header only"] --> L2["Cart page and Checkout can't see it - unusable for this need"]
end
subgraph P["Lifted state"]
P1["cart lifted to common parent App"] --> P2["Passed down as props to Header, CartPage, Checkout"]
P2 --> P3["Every layout in between forwards props it doesn't use"]
end
subgraph C["Context"]
C1["CartProvider wraps the app"] --> C2["Header, CartPage, Checkout each call useCart() directly"]
C2 --> C3["No prop drilling, but any cart change re-renders every consumer"]
end
subgraph S["External store: Redux or Zustand"]
S1["Store holds cart outside the component tree"] --> S2["Components subscribe via selector, e.g. state => state.cart.length"]
S2 --> S3["Only components using the changed slice re-render"]
end
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.