3. State Management
Choosing where state lives is the most common React design question in interviews.
1. The Levels of State
| Level | Tool | Example |
|---|---|---|
| Local | useState, useReducer | Modal open, input value |
| Shared between siblings | Lift state to parent | Selected row + detail pane |
| App-wide, rarely changing | Context | Theme, language, current user |
| App-wide, frequently changing | Redux / Zustand / Jotai | Cart, editor document |
| Server data | React Query / SWR / RTK Query | User 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.
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.
<App user={user}>
<Layout user={user}>
<Header user={user}>
<Avatar user={user} /> // only this one needs itProblems
- 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
| Fix | When |
|---|---|
Component composition (children) | The simplest fix, often forgotten |
| Context API | Same value needed by many components at many depths |
| State library | The value also changes frequently |
Composition Fix (Underrated)
// Instead of drilling user through Layout
<Layout header={<Header><Avatar user={user} /></Header>} />Layout never sees user at all.
4. Context API
Setup
// 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
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:
- Single source of truth (one store)
- State is read-only (change it only by dispatching actions)
- Changes are made by pure reducers
Core Concepts
| Term | Meaning |
|---|---|
| Store | Holds the whole state tree |
| Action | Plain object describing what happened: { type, payload } |
| Reducer | (state, action) => newState, pure |
| Dispatch | Sends an action to the store |
| Selector | Reads a slice of state |
| Middleware | Intercepts actions (thunk, logger, RTK Query) |
Modern Redux (Redux Toolkit)
// 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;// store.js
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cartSlice";
export const store = configureStore({
reducer: { cart: cartReducer },
});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
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.
import { create } from "zustand";
export const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clear: () => set({ items: [] }),
}));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.
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
| Context | Redux Toolkit | Zustand | React Query | |
|---|---|---|---|---|
| Boilerplate | Low | Medium | Very low | Low |
| Selectors | No | Yes | Yes | N/A |
| DevTools | No | Excellent | Yes | Yes |
| Middleware | No | Yes | Yes | N/A |
| Bundle size | 0 (built in) | ~12 KB | ~1 KB | ~13 KB |
| Best for | Theme, auth, locale | Large complex client state | Most client state | Anything 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:
| Direction | Method |
|---|---|
| Parent → Child | Props |
| Child → Parent | Callback function passed as a prop |
| Sibling → Sibling | Lift state to the common parent |
| Deeply nested | Context API |
| Anywhere → Anywhere | Redux / Zustand |
| Across routes | Router state, URL params, or search params |
Sibling Data Via React Router
// 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.