Topics in this subject
React 7 min read Updated 6 Aug 2026

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.