Section 5: State Management — Deep Dive
State management is the most over-complicated topic in React interviews. Most candidates reach for Redux the moment something sounds "global." The skill is knowing which tool fits the problem — and being able to defend that choice.
The Decision Framework (Read This First)
Before picking a tool, ask in order:
1. Can this state stay LOCAL to one component?
→ YES: useState / useReducer. Done.
2. Do two or more siblings need to share it?
→ YES: Lift it to the closest common ancestor (props down, callbacks up).
3. Is it needed far down the tree across unrelated components?
→ YES: Context API (for slow-changing global data: auth, theme, locale)
→ OR: Zustand/Redux (for frequently-changing or large shared state)
4. Is it data that lives on a SERVER (fetched from API, can go stale)?
→ React Query / TanStack Query. This is NOT the same problem as client state.90% of apps only need steps 1–3. Step 4 is where most devs waste time trying to put server data into Redux when React Query is purpose-built for it.
5.1 Lifting State Up
What It Is
When two sibling components need to share state, move that state up to their closest common parent. The parent owns the state; both siblings receive it as props.
The rule: State should live as close to where it's used as possible, but no further up than needed.
🔍 Your Code: Lifting Modal State — Home.jsx
// Home.jsx — Lines 19-23: modal state is "lifted" into Home so multiple
// children can trigger or receive it
const [editingLink, setEditingLink] = useState(null); // which link is being edited
const [deletingLink, setDeletingLink] = useState(null); // which link is being deleted
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// LinkList triggers the edit → sets editingLink UP in Home
<LinkList
onEdit={(link) => setEditingLink(link)} // ← callback pushed DOWN
onDelete={(link) => setDeletingLink(link)} // ← callback pushed DOWN
/>
// EditLinkModal READS the editingLink state from Home
{editingLink && (
<EditLinkModal
link={editingLink} // ← data pushed DOWN
onClose={() => setEditingLink(null)} // ← callback pushed DOWN
onSave={handleEdit}
saving={saving}
/>
)}Why this is lifted: Both LinkList (which triggers "open the edit modal") and EditLinkModal (which IS the edit modal) need to know about editingLink. Their closest common ancestor is Home, so the state lives there.
If editingLink lived inside LinkList, EditLinkModal couldn't see it — they're siblings.
🔍 Your Code: links Array — Classic Lifted State
// Home.jsx — Lines 15, 47-51, 69-71, 85: the `links` array is lifted into Home
// because THREE operations (toggle, edit, delete) all need to modify it,
// and ALL those mutations propagate back to LinkList which renders the list.
const [links, setLinks] = useState([]);
// Toggle: update one item in-place
setLinks((prev) =>
prev.map((l) => l.id === linkId ? { ...l, isActive: data.isActive } : l)
);
// Edit: update one item's fields
setLinks((prev) =>
prev.map((l) => (l.id === linkId ? { ...l, ...data.link } : l))
);
// Delete: filter it out
setLinks((prev) => prev.filter((l) => l.id !== linkId));All three handlers modify the same links array — this is exactly the signal that the state belongs in their common parent (Home), not scattered across child components.
📝 Prop Drilling Problem (When Lifting Isn't Enough)
Lifting works well when the distance is 1–2 levels. When state needs to travel through many layers of components that don't use it, it becomes "prop drilling":
// 4 levels deep just to get `user` to UserAvatar
<App>
<Dashboard user={user}> // doesn't use user — just passes it
<Sidebar user={user}> // doesn't use user — just passes it
<NavMenu user={user}> // doesn't use user — just passes it
<UserAvatar user={user} /> // finally uses it
</NavMenu>
</Sidebar>
</Dashboard>
</App>Dashboard, Sidebar, NavMenu all carry user only to hand it deeper. This is the problem Context solves.
[!TIP] Interview line: "Lifting state is always my first move — it's the simplest solution. I only reach for Context when I find myself passing the same prop through 3+ levels of components that don't use it themselves."
5.2 Context API — Deeper Dive & Pitfalls
What It Is
A way to teleport a value from a Provider anywhere in the tree to any consumer component, skipping all the intermediate layers.
🔍 Your Code: Full Context Implementation — AppContext.jsx
// AppContext.jsx — full breakdown
// 1. Create the context (null is the default if no Provider is found above)
const AppContext = createContext(null);
// 2. The Provider component — owns the state and broadcasts it
export const AppProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [isAuth, setIsAuth] = useState(false);
useEffect(() => { fetchUser(); }, []);
async function fetchUser() {
setLoading(true);
try {
const { data } = await api.get(`/api/v1/me`);
setUser(data.user);
setIsAuth(true);
} catch {
setUser(null);
setIsAuth(false);
} finally {
setLoading(false);
}
}
async function logout() {
// ... clears user + isAuth
}
// 3. Provide the value to the entire subtree
return (
<AppContext.Provider value={{ isAuth, setIsAuth, user, setUser, fetchUser, loading, logout }}>
{children}
</AppContext.Provider>
);
};
// 4. Custom hook for consuming — encapsulates useContext + error guard
export const AppData = () => {
const context = useContext(AppContext);
if (!context) throw new Error("AppData must be used within an AppProvider");
return context;
};Three things worth noting in your implementation:
① The error guard is excellent:
if (!context) throw new Error("AppData must be used within an AppProvider");Without this, forgetting to wrap a component in <AppProvider> gives you a cryptic Cannot read properties of null error. The explicit error message tells you exactly what went wrong.
② You export a custom hook (AppData) not raw useContext:
// ✅ Your way — consumers don't need to import AppContext at all:
const { isAuth, logout } = AppData();
// ❌ Without this abstraction — consumers import both:
import { AppContext } from './context/AppContext';
const { isAuth, logout } = useContext(AppContext);The custom hook is cleaner. Every consumer calls AppData() — they don't care that Context is the mechanism.
③ You expose setIsAuth and setUser directly in the context value:
value={{ isAuth, setIsAuth, user, setUser, fetchUser, loading, logout }}This works but is a mild code smell — any consumer can directly call setIsAuth(true) without going through fetchUser() or logout(). A stricter design would only expose fetchUser and logout (which set those values internally), keeping the state transitions centralized. Fine for a project this size; worth noting in interviews as a trade-off.
🔍 How Consumers Use It — App.jsx + Logout.jsx
// App.jsx — Line 19: reads isAuth + loading from Context
// App is deeply nested in the tree but gets auth state without prop drilling
const { isAuth, loading } = AppData();
// Logout.jsx — gets the logout function from Context
// Logout component lives many levels deep, but doesn't need logout passed as a prop
const { logout } = AppData();
return <button onClick={logout}>Logout</button>;Logout is rendered inside Home → inside App → wrapped by AppProvider. It reads logout from Context without Home or App needing to pass it down. That's exactly the prop drilling problem Context solves.
⚠️ The Re-Render Problem — Your Context Has It
// AppContext.jsx — Line 45: the context value is ONE object
<AppContext.Provider value={{ isAuth, setIsAuth, user, setUser, fetchUser, loading, logout }}>The problem: Every time ANY of these values changes, React re-renders every single component that calls AppData() — even if it only uses isAuth and loading changed, or it only uses logout and user changed.
AppProvider re-renders (loading changed)
↓
context value = new object reference
↓
App.jsx re-renders (uses isAuth + loading — makes sense)
Logout.jsx re-renders (uses only logout — UNNECESSARY)Fix: Split into two contexts by change frequency:
// AuthContext — changes rarely (login/logout)
const AuthContext = createContext(null);
// ActionsContext — never changes (functions are stable)
const AuthActionsContext = createContext(null);
export const AppProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isAuth, setIsAuth] = useState(false);
const [loading, setLoading] = useState(true);
const logout = useCallback(async () => { /* ... */ }, []);
const fetchUser = useCallback(async () => { /* ... */ }, []);
return (
// Actions context never changes → logout consumers never re-render
<AuthActionsContext.Provider value={{ logout, fetchUser }}>
// State context changes on login/logout
<AuthContext.Provider value={{ user, isAuth, loading }}>
{children}
</AuthContext.Provider>
</AuthActionsContext.Provider>
);
};
// Consumers only subscribe to what they need:
const { logout } = useContext(AuthActionsContext); // never re-renders due to state changes
const { isAuth } = useContext(AuthContext); // only re-renders when auth changes[!IMPORTANT] The core rule: Every component that calls
useContext(SomeContext)re-renders whenever the context value changes, regardless of which part of the value it actually reads. This is the #1 Context pitfall in interviews.
📝 When Context is the RIGHT tool vs the WRONG tool
| ✅ Right for Context | ❌ Wrong for Context |
|---|---|
| Current logged-in user | A list of links that changes on every edit/delete/toggle |
| Theme (dark/light) | A search query that changes on every keystroke |
| Locale/language | Real-time data (WebSocket messages, analytics stream) |
| Feature flags | Anything that changes more than a few times per session |
Your AppContext is correct — auth state changes rarely (on login and logout). That's exactly the right use case.
5.3 Redux / Redux Toolkit
What It Is
A global store with a strict pattern: state lives in one place, and changes only happen through actions processed by pure reducer functions.
Component → dispatch(action) → reducer(state, action) → new state → re-render subscribers📝 What Redux Toolkit Looks Like (Applied to Your App)
If Snips scaled to a product with many engineers, the links array from Home.jsx might move into Redux:
// store/linksSlice.js
import { createSlice } from '@reduxjs/toolkit';
const linksSlice = createSlice({
name: 'links',
initialState: {
items: [],
loading: false,
error: null,
},
reducers: {
// Redux Toolkit uses Immer under the hood → you can "mutate" directly
setLinks: (state, action) => {
state.items = action.payload;
},
toggleLink: (state, action) => {
const link = state.items.find(l => l.id === action.payload.id);
if (link) link.isActive = action.payload.isActive;
},
updateLink: (state, action) => {
const idx = state.items.findIndex(l => l.id === action.payload.id);
if (idx !== -1) state.items[idx] = { ...state.items[idx], ...action.payload };
},
deleteLink: (state, action) => {
state.items = state.items.filter(l => l.id !== action.payload);
},
setLoading: (state, action) => { state.loading = action.payload; },
},
});
export const { setLinks, toggleLink, updateLink, deleteLink, setLoading } = linksSlice.actions;
export default linksSlice.reducer;// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import linksReducer from './linksSlice';
export const store = configureStore({
reducer: {
links: linksReducer,
},
});// In the component — useSelector reads, useDispatch writes
import { useSelector, useDispatch } from 'react-redux';
import { toggleLink, deleteLink } from '../store/linksSlice';
function Home() {
// useSelector — subscribes to ONLY the slice it reads
// This component re-renders ONLY when links.items changes
const links = useSelector(state => state.links.items);
const loading = useSelector(state => state.links.loading);
const dispatch = useDispatch();
const handleToggle = async (linkId) => {
const { data } = await api.patch(`/api/v1/${linkId}/toggle`);
dispatch(toggleLink({ id: linkId, isActive: data.isActive }));
// ↑ action object — processed by reducer
};
const handleDelete = async (linkId) => {
await api.delete(`/api/v1/${linkId}`);
dispatch(deleteLink(linkId));
};
return <LinkList links={links} onToggle={handleToggle} onDelete={handleDelete} />;
}Key difference from your current code:
- Your
Home.jsx:linksstate lives in Home. Only Home and its children can see/modify it. - Redux:
linksstate lives in the store. Any component anywhere in the app candispatchto update it oruseSelectorto read it.
⚠️ useSelector vs Context — The Critical Difference
// Context: re-renders EVERY consumer when any part of context changes
const { links, loading, user, isAuth } = useContext(AppContext);
// ^ if `loading` changes, this component re-renders even if it only uses `links`
// Redux: re-renders ONLY when the selected slice changes
const links = useSelector(state => state.links.items);
// ^ if `loading` or `user` changes in the store, this component is NOT re-rendered
// React-Redux compares the selected value (shallow equality by default)This selective re-rendering is Redux's key win over a naive Context.
📝 When Redux Is Worth Its Complexity
Signals Redux earns its keep:
✅ 10+ components across the app all reading/writing the same state
✅ You need time-travel debugging (Redux DevTools replay)
✅ Complex state transitions (many actions, conditional logic)
✅ Multiple developers — centralized state transitions are easier to review
✅ The state is CROSS-CUTTING — unrelated features both need it
Signals Redux is overkill:
❌ 1-2 components share state → just lift it
❌ State only flows down from one parent → just use props
❌ The "shared" data is server data (links list, analytics) → use React Query
❌ App is one engineer, one feature area → Context is fine[!IMPORTANT] Interview line: "For Snips, I'd honestly skip Redux. The links data is server state (better handled by React Query), and auth state is infrequently-changing global state (fine in Context). Redux earns its complexity when you have many engineers working on many features that all touch the same frequently-updated client state."
5.4 Zustand — The Middle Ground
What It Is
A minimal, hook-based global store. No actions, no reducers, no Provider needed. Just create a store and call it from any component.
📝 Zustand Equivalent of Your links State
// store/useLinksStore.js
import { create } from 'zustand';
const useLinksStore = create((set) => ({
// State
items: [],
loading: false,
// Actions (just plain functions — no action type strings)
setLinks: (links) => set({ items: links }),
setLoading: (loading) => set({ loading }),
toggleLink: (id, isActive) =>
set((state) => ({
items: state.items.map(l => l.id === id ? { ...l, isActive } : l),
})),
deleteLink: (id) =>
set((state) => ({
items: state.items.filter(l => l.id !== id),
})),
updateLink: (id, updates) =>
set((state) => ({
items: state.items.map(l => l.id === id ? { ...l, ...updates } : l),
})),
}));// In any component — no Provider needed, no boilerplate:
function Home() {
// Zustand selectors — same selective re-render benefit as Redux's useSelector
const links = useLinksStore(state => state.items);
const loading = useLinksStore(state => state.loading);
const { toggleLink, deleteLink, updateLink, setLinks } = useLinksStore();
useEffect(() => {
api.get('/api/v1/my-links').then(r => setLinks(r.data.links));
}, []);
return <LinkList links={links} onToggle={toggleLink} onDelete={deleteLink} />;
}
// And from a completely unrelated component — no prop drilling:
function LinkCounter() {
const count = useLinksStore(state => state.items.length);
return <span>{count} links</span>;
}Redux vs Zustand Side-by-Side
For the same "toggle a link's isActive" operation:
REDUX (more ceremony):
1. Define action type string: 'links/toggleLink'
2. createSlice reducer handles it
3. Export action creator: toggleLink()
4. In component: dispatch(toggleLink({ id, isActive }))
5. useSelector to read
ZUSTAND (minimal):
1. Define a function in the store: toggleLink: (id, isActive) => set(...)
2. In component: const { toggleLink } = useLinksStore()
3. Call: toggleLink(id, isActive)| Redux Toolkit | Zustand | |
|---|---|---|
| Setup | configureStore + slices + Provider | create() — done |
| Read | useSelector(state => ...) | useStore(state => ...) |
| Write | dispatch(actionCreator()) | Call store function directly |
| DevTools | ✅ Excellent (time-travel) | ⚠️ Basic |
| Bundle size | ~47kb | ~3kb |
| Best for | Large teams, complex state, need devtools | Medium apps, simple global state |
[!TIP] Interview line: "Zustand gives you Redux's key benefit — selective re-rendering via selectors — with almost no boilerplate. For a new project without massive scale, I'd reach for Zustand before Redux. Redux is still better when you need time-travel debugging or have strict architectural conventions across a large team."
5.5 Client State vs Server State — The Most Important Distinction
The Mental Model
Most state management debates miss this split entirely:
CLIENT STATE SERVER STATE
───────────────────────────── ─────────────────────────────
"Is the modal open?" "What links does this user have?"
"What's in the search box?" "How many clicks today?"
"Which tab is active?" "Is this slug already taken?"
"Dark mode or light mode?" "What's the user's name/email?"
Lives in the browser. Lives on your server.
You own it 100%. Could change from another browser tab.
Never "goes stale." Can go stale — someone else edited it.
useReducer/Context/Zustand React Query / TanStack Query🔍 Your Code: Server State Managed Manually — Home.jsx
// Home.jsx — manual server state management (the hard way):
const [links, setLinks] = useState([]); // ← storing server data in client state
const [linksLoading, setLinksLoading] = useState(true);
// No: error state, no: stale time, no: background refetch, no: dedup
useEffect(() => {
const fetchData = async () => {
try {
const [userRes, linksRes] = await Promise.all([
api.get("/api/v1/me"),
api.get("/api/v1/my-links"),
]);
setLinks(linksRes.data.links); // ← manually push into state
} catch {
toast.error("Failed to load data"); // ← manual error handling
} finally {
setLinksLoading(false); // ← manual loading flag
}
};
fetchData();
}, []); // ← no caching, fetches fresh every page visitProblems with this approach:
- Navigate away and back → fetches again (no caching)
- Two components fetch
/api/v1/my-links→ two HTTP requests (no dedup) - User edits a link in another tab → your data is stale (no background refetch)
- Every component repeats the
loading/errorboolean boilerplate
📝 The Same Data with React Query (TanStack Query)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function Home() {
const queryClient = useQueryClient();
// All the useEffect + useState + error handling replaced by ONE hook
const {
data: links = [],
isLoading,
error
} = useQuery({
queryKey: ['my-links'], // cache key
queryFn: () => api.get('/api/v1/my-links').then(r => r.data.links),
staleTime: 30_000, // don't refetch for 30s
});
// Mutations auto-invalidate the cache → UI stays in sync
const toggleMutation = useMutation({
mutationFn: (linkId) => api.patch(`/api/v1/${linkId}/toggle`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['my-links'] });
// ↑ tells React Query "this data might be stale now, refetch it"
},
});
const deleteMutation = useMutation({
mutationFn: (linkId) => api.delete(`/api/v1/${linkId}`),
onSuccess: (_, linkId) => {
// Optimistic update — remove from cache immediately, don't wait for refetch
queryClient.setQueryData(['my-links'], (old) =>
old.filter(l => l.id !== linkId)
);
},
});
if (isLoading) return <Spinner />;
if (error) return <ErrorBanner />;
return (
<LinkList
links={links}
onToggle={id => toggleMutation.mutate(id)}
onDelete={id => deleteMutation.mutate(id)}
/>
);
}What React Query handles automatically:
- ✅ Caching (navigate away/back = no refetch for 30s)
- ✅ Background refetch (data stays fresh when you come back to the tab)
- ✅ Deduplication (if two components query the same key, one HTTP request)
- ✅ Loading/error states (no manual boolean wiring)
- ✅ Race condition protection (stale queries are ignored)
- ✅ Retry on failure (3 retries with exponential backoff by default)
📝 The Practical Mental Map for Your App
State in Snips What Tool to Use
──────────────────────────────────────────────────────────────────
isAuth, user object, loading Context (AppContext.jsx — already correct)
Dark/light theme Local useState (ThemeToggle.jsx — already correct)
editingLink, deletingLink Local useState in Home.jsx (already correct)
"Is modal open?" Local useState (already correct)
──────────────────────────────────────────────────────────────────
links array (from API) → React Query (currently: manual useEffect)
analytics data (from API) → React Query (currently: manual useEffect)
overview stats (from API) → React Query (currently: manual useEffect)
user profile from /api/v1/me → React Query (currently: mixed — in Context AND Home)The bottom four are all server state being managed with client state tools — they work, but React Query would remove a significant amount of manual boilerplate and fix the caching/dedup issues.
📝 Query Keys — The Key Concept in React Query
// queryKey is the cache identifier — like a primary key for your cache
useQuery({ queryKey: ['links'] }) // all links
useQuery({ queryKey: ['analytics', linkId] }) // specific link's analytics
useQuery({ queryKey: ['analytics', linkId, range] }) // analytics for a specific range
// When range changes in LinkAnalytics.jsx, the key changes → new cache entry → new fetch
// Navigate to a different link → id changes → different cache entry
// Navigate BACK → same key → cache hit, no refetch (within staleTime)This is why React Query solves the race condition automatically: each (id, range) combination is a separate cache entry with its own lifecycle.
🔥 Full Section Interview Quick-Fire
| Question | Answer |
|---|---|
| When do you lift state? | When two sibling components need to share state — move it to their closest common ancestor. Always try this before Context or Redux. |
| What's prop drilling? | Passing a prop through intermediate components that don't use it just to get it to a deep child. Context solves this. |
| Context re-render problem? | Every component calling useContext(X) re-renders when X's value changes, even if it only reads one field. Fix: split into multiple contexts by change frequency, or use a state library with selectors. |
| Context vs Redux? | Context is a dependency-injection mechanism — no selectors, no actions, no devtools. Redux is a state management library that adds selective re-rendering, time-travel debugging, and strict update patterns on top. |
| Redux vs Zustand? | Both give selective re-rendering via selectors. Zustand has minimal boilerplate and tiny bundle. Redux has better devtools and is stricter (better for large teams). |
| Client state vs server state? | Client state = UI state you own (modal open?, current tab). Server state = data from an API that can go stale and might change elsewhere. React Query is purpose-built for server state; Redux/Context are for client state. |
| What does React Query give you? | Caching, background refetch, request deduplication, race condition protection, loading/error states — all automatic. |
useSelector vs useContext? | useSelector re-renders only when the selected slice changes. useContext re-renders on ANY change to the context value. useSelector is more surgical. |
What's staleTime in React Query? | How long React Query considers fetched data "fresh" before background-refetching. staleTime: 30_000 = data stays fresh for 30 seconds, no refetch within that window. |
🏋️ Practice Exercises
-
Open AppContext.jsx — identify which parts of the context value change often vs rarely. Sketch how you'd split it into two contexts (
AuthStateContext+AuthActionsContext) to prevent unnecessary re-renders onlogout-only consumers. -
Open Home.jsx — count how many
useStatevariables are there just to manage loading/error/data for the links fetch (lines 15-23). Now look at how React Query'suseQuerywould collapse those into one call. Can you write that replacement? -
Explain out loud (no looking): Why does
useSelectorprevent more re-renders thanuseContext? (BecauseuseSelectorcompares the return value of your selector before and after a state change — if equal, no re-render.useContextre-renders on any object reference change in the context value.) -
Write from scratch: A Zustand store for the
linksarray withsetLinks,toggleLink,deleteLink, andupdateLinkactions. This is a real interview ask for "design a global store for a link manager app."