Skip to content

3. State Management

Choosing where state lives is the most common React design question in interviews.


1. The Levels of State

LevelToolExample
LocaluseState, useReducerModal open, input value
Shared between siblingsLift state to parentSelected row + detail pane
App-wide, rarely changingContextTheme, language, current user
App-wide, frequently changingRedux / Zustand / JotaiCart, editor document
Server dataReact Query / SWR / RTK QueryUser list from an API

Interview Point

Say this and you sound senior: "Most 'global state' is actually server cache. Once you move that to React Query, the remaining global state is usually small enough for Context or Zustand."


2. Lifting State Up

When two siblings need the same value, move it to their closest common parent.

jsx
function Parent() {
  const [selectedId, setSelectedId] = useState(null);

  return (
    <>
      <List onSelect={setSelectedId} />
      <Detail id={selectedId} />
    </>
  );
}

Data flows down as props, events flow up as callbacks.


3. Prop Drilling

Passing props through components that don't use them, just to reach a deep child.

jsx
<App user={user}>
  <Layout user={user}>
    <Header user={user}>
      <Avatar user={user} />   // only this one needs it

Problems

  • Middle components change whenever an unrelated prop changes
  • Refactoring is painful — moving a component means rewiring the chain
  • Noise: components carry props they never read

Fixes

FixWhen
Component composition (children)The simplest fix, often forgotten
Context APISame value needed by many components at many depths
State libraryThe value also changes frequently

Composition Fix (Underrated)

jsx
// Instead of drilling user through Layout
<Layout header={<Header><Avatar user={user} /></Header>} />

Layout never sees user at all.


4. Context API

Setup

jsx
// AuthContext.jsx
const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = useCallback(async (creds) => {
    const u = await api.login(creds);
    setUser(u);
  }, []);

  const logout = useCallback(() => setUser(null), []);

  const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
  return ctx;
}

The useMemo matters: without it, value is a new object every render and every consumer re-renders.

The custom useAuth hook with a null check is a pattern interviewers like — it fails loudly instead of silently returning undefined.

The Context Re-render Problem

Every consumer of a context re-renders when the provider value changes, even if it only reads one field.

Fix — Split Contexts

jsx
const UserContext = createContext(null);       // changes rarely
const UserActionsContext = createContext(null); // never changes

<UserContext.Provider value={user}>
  <UserActionsContext.Provider value={actions}>
    {children}
  </UserActionsContext.Provider>
</UserContext.Provider>

Components that only dispatch actions never re-render when user changes.

Interview Point

"Why not use Context for everything?" — because Context has no selector. You cannot subscribe to one slice of the value. Redux and Zustand can, which is exactly why they still exist.


5. Redux Toolkit

Redux is a predictable state container built on three principles:

  1. Single source of truth (one store)
  2. State is read-only (change it only by dispatching actions)
  3. Changes are made by pure reducers

Core Concepts

TermMeaning
StoreHolds the whole state tree
ActionPlain object describing what happened: { type, payload }
Reducer(state, action) => newState, pure
DispatchSends an action to the store
SelectorReads a slice of state
MiddlewareIntercepts actions (thunk, logger, RTK Query)

Modern Redux (Redux Toolkit)

js
// cartSlice.js
import { createSlice } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    addItem(state, action) {
      state.items.push(action.payload); // Immer makes this safe
    },
    removeItem(state, action) {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },
  },
});

export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;
js
// store.js
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cartSlice";

export const store = configureStore({
  reducer: { cart: cartReducer },
});
jsx
function Cart() {
  const items = useSelector((state) => state.cart.items);
  const dispatch = useDispatch();

  return <button onClick={() => dispatch(removeItem(id))}>Remove</button>;
}

Interview Point

The state.items.push() line looks like a mutation but is not. RTK uses Immer, which records your mutations against a draft and produces a new immutable state. If you say "Redux requires immutable updates" without mentioning Immer, expect a follow-up.

Async with createAsyncThunk

js
export const fetchUsers = createAsyncThunk("users/fetch", async () => {
  const res = await fetch("/api/users");
  return res.json();
});

const usersSlice = createSlice({
  name: "users",
  initialState: { data: [], status: "idle" },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => { state.status = "loading"; })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.data = action.payload;
      })
      .addCase(fetchUsers.rejected, (state) => { state.status = "failed"; });
  },
});

6. Zustand

A minimal store. No provider, no boilerplate.

js
import { create } from "zustand";

export const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  clear: () => set({ items: [] }),
}));
jsx
function Cart() {
  // Selector — this component re-renders ONLY when items changes
  const items = useCartStore((state) => state.items);
  const clear = useCartStore((state) => state.clear);
}

Why Teams Pick It

  • No <Provider> wrapper
  • Built-in selectors (the thing Context lacks)
  • ~1 KB, works outside React too
  • No actions/reducers ceremony

7. React Query / TanStack Query

For server state, not client state.

jsx
function Users() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ["users"],
    queryFn: () => fetch("/api/users").then((r) => r.json()),
    staleTime: 60_000,
  });

  if (isLoading) return <Spinner />;
  if (error) return <p>{error.message}</p>;
  return <List items={data} />;
}

What It Gives You For Free

  • Caching and deduplication of identical requests
  • Background refetch on window focus and reconnect
  • Loading, error and stale states
  • Pagination and infinite scroll helpers
  • Optimistic updates and rollback

Interview Point

Client state vs server state is a real distinction. Server state is asynchronous, shared, and can go stale without you touching it. Redux was never designed for that, which is why "we replaced half our Redux store with React Query" is a very common real-world answer.


8. Comparison Table

ContextRedux ToolkitZustandReact Query
BoilerplateLowMediumVery lowLow
SelectorsNoYesYesN/A
DevToolsNoExcellentYesYes
MiddlewareNoYesYesN/A
Bundle size0 (built in)~12 KB~1 KB~13 KB
Best forTheme, auth, localeLarge complex client stateMost client stateAnything from an API

9. Can Hooks Replace Redux?

Common trick question. The honest answer:

Partly. useReducer + Context gives you the reducer pattern and global access. What it does not give you:

  • Selector-based subscriptions (Context re-renders all consumers)
  • Middleware (logging, persistence, side effects)
  • Time-travel DevTools
  • A single store usable outside the React tree

Interview Point

For a small or medium app, useReducer + Context is genuinely enough. For a large app with frequent updates, the missing selector layer becomes a real performance problem. Answer with the trade-off, not with "yes" or "no".


10. How To Pass Data Between Components

Interviewers ask this as one question with five answers:

DirectionMethod
Parent → ChildProps
Child → ParentCallback function passed as a prop
Sibling → SiblingLift state to the common parent
Deeply nestedContext API
Anywhere → AnywhereRedux / Zustand
Across routesRouter state, URL params, or search params

Sibling Data Via React Router

jsx
// Sender
navigate("/detail", { state: { userId: 7 } });

// Receiver
const location = useLocation();
const { userId } = location.state ?? {};

Prefer URL params over router state for anything the user should be able to bookmark or refresh.

© 2025 DDocs · Dipak's Documentation Guide