React Development · Lesson 14 of 15
Interview Prep: Mid-Level (50 Q)
Mid-Level Interview Signals
At this level, interviewers probe for depth not breadth. They want to see you understand tradeoffs, know when not to use something, and can reason about why React works the way it does. Surface-level answers get cut off quickly.
Hooks Deep Dive
Q1: What is the correct mental model for useEffect? How does it differ from lifecycle methods?
Wrong answer: "It's like componentDidMount + componentDidUpdate + componentWillUnmount."
Right answer: useEffect synchronizes your component with an external system. The question to ask isn't "when does this run?" but "what external thing should I be in sync with?"
// Think: "I need to be in sync with the WebSocket for this orderId"
useEffect(() => {
const ws = new WebSocket(`wss://api/orders/${orderId}`);
ws.onmessage = (e) => setStatus(JSON.parse(e.data).status);
// Cleanup = stop synchronizing with the old orderId
return () => ws.close();
}, [orderId]);
// This also runs on mount (initial sync) and on orderId change (re-sync)The lifecycle analogy fails because: (1) effects run after every render when deps change, not just mount/update, and (2) the mental model of "lifecycle" leads you to miss cleanup bugs.
Q2: Why does React 18 Strict Mode run effects twice in development?
To help you find effects that don't properly clean up. React mounts → runs effect → unmounts → mounts again → runs effect again. If your effect creates a subscription but the cleanup doesn't cancel it, you'll have two subscriptions — you'll see the bug immediately.
// This breaks under Strict Mode — two intervals created, one never cleared
useEffect(() => {
const id = setInterval(tick, 1000);
// Missing cleanup!
}, []);
// This is safe
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // Cleanup
}, []);This behavior only happens in development. Production is unaffected.
Q3: Explain the exhaustive-deps ESLint rule. When would you legitimately disable it?
The rule ensures your useEffect dependency array matches everything the effect actually reads. Missing deps causes stale closures — the effect uses an old value.
// ESLint warns: 'userId' is missing from dependencies
useEffect(() => {
fetchUser(userId).then(setUser); // userId is read here
}, []); // ← Bug: always uses the userId from the first render
// Correct
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Refetches when userId changesLegitimate reasons to disable: (1) stable external references like dispatch from Redux (guaranteed stable), (2) intentionally running only on mount when you're certain the value won't change, with a comment explaining why.
Q4: What is the difference between useMemo and useCallback?
useMemo— caches a computed value:useMemo(() => expensiveCalc(data), [data])useCallback— caches a function reference:useCallback(() => handleClick(id), [id])
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).
When they actually help: Only when the value/function is consumed by a component wrapped in React.memo or as a useEffect dependency. Otherwise they add overhead with no benefit.
Q5: What is a stale closure in React and how do you prevent it?
A stale closure occurs when an effect or callback "captures" a variable from a render but that variable gets updated in a later render — the old function still refers to the old value.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
// ❌ Stale closure: count is captured at mount (value: 0)
// The interval always alerts 0, even as count updates
const id = setInterval(() => {
alert(`Count: ${count}`);
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps = only runs on mount
}
// Fix 1: Add count to deps (re-creates interval each time)
}, [count]);
// Fix 2: Use functional update (doesn't need count in scope)
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // No stale closure — prev is always current
}, 1000);
return () => clearInterval(id);
}, []);
// Fix 3: useRef for latest value
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useEffect(() => {
const id = setInterval(() => alert(countRef.current), 1000);
return () => clearInterval(id);
}, []);Performance
Q6: When does React.memo actually prevent a re-render?
React.memo skips the re-render only when props are shallowly equal. It does NOT help when:
// ❌ New object reference every render — memo is useless
<MemoChild config={{ size: "large" }} />
// ❌ New function reference every render
<MemoChild onClick={() => handleClick(id)} />
// ✅ Both must be stable
const config = useMemo(() => ({ size: "large" }), []);
const handleClick = useCallback(() => onDelete(id), [id, onDelete]);
<MemoChild config={config} onClick={handleClick} />Also, React.memo doesn't help when: the component has its own state changes, or when a context the component consumes changes.
Q7: Explain the two-phase commit in React rendering.
-
Render phase (pure, interruptible in React 18): React calls your component functions, builds the new virtual DOM tree, and diffs it against the previous one. No side effects — this can be interrupted and restarted.
-
Commit phase (synchronous, can't be interrupted): React applies the computed DOM changes, then runs
useLayoutEffect, thenuseEffect. This phase always runs to completion.
Why it matters: useLayoutEffect fires synchronously in the commit phase (before paint) — use it for measuring DOM or preventing visual flicker. useEffect fires asynchronously after paint — use it for everything else.
Q8: What is virtualization and when should you use it?
Virtualization renders only DOM nodes visible in the viewport. For a 10,000-item list, only ~20-30 rows exist in the DOM at any time. The rest are represented by spacer elements.
Use it when: rendering 100+ items AND the items are complex enough that 100 DOM nodes causes measurable slowness. Don't use it for simple lists under 100 items.
import { useVirtualizer } from "@tanstack/react-virtual";
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 72, // estimated row height
});Component Patterns
Q9: What problem does lifting state up solve and what are its limits?
Lifting state up solves sharing state between siblings: move state to the nearest common parent, pass it down as props. It's the right solution for 2-3 nearby components.
Limits: with deep hierarchies, lifting creates prop drilling — passing props through many layers of components that don't use them. This is when Context becomes useful.
Q10: What is prop drilling and what are the solutions?
Prop drilling: passing props through intermediate components that don't use them, just to reach a deeply nested consumer.
Solutions (in order of preference):
- Component composition — pass components as children/props, avoid intermediate layers
- React Context — for app-wide state read by many components (theme, auth, locale)
- State management library — Redux/Zustand for complex, frequently-updated state
// Prop drilling problem
<App>
<Layout user={user}>
<Sidebar user={user}>
<UserAvatar user={user} /> {/* Only this needs user */}
</Sidebar>
</Layout>
</App>
// Composition solution — UserAvatar is passed as children
<App>
<Layout sidebar={<Sidebar><UserAvatar user={user} /></Sidebar>}>
{/* Layout doesn't need to know about user */}
</Layout>
</App>Q11: What is the render props pattern?
A component that accepts a function as a prop and calls it to decide what to render. Useful for sharing logic while letting the consumer control the output.
function MouseTracker({ render }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
{render(pos)}
</div>
);
}
// Usage
<MouseTracker
render={({ x, y }) => <Tooltip x={x} y={y}>Hover info</Tooltip>}
/>Modern equivalent: a custom hook (useMousePosition()). Render props are still useful for libraries that need to control rendering (like react-table, formik, downshift).
Q12: When would you choose useReducer over useState?
Use useReducer when:
- State has multiple sub-values that change together
- The next state depends on complex logic from previous state
- You have many different state transitions (actions)
- You want to colocate state transition logic with the component
// Classic sign: lots of related useState + complex update logic
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
// → Use useReducer
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: Data }
| { type: "FETCH_ERROR"; message: string }
| { type: "NEXT_PAGE" };
function reducer(state, action) {
switch (action.type) {
case "FETCH_START": return { ...state, loading: true, error: null };
case "FETCH_SUCCESS": return { loading: false, data: action.payload, error: null, page: state.page };
case "FETCH_ERROR": return { ...state, loading: false, error: action.message };
case "NEXT_PAGE": return { ...state, page: state.page + 1 };
}
}Routing
Q13: What is the difference between <Link> and <NavLink> in React Router?
Both render an <a> tag. NavLink adds active state support: its className prop receives an isActive boolean, letting you style the active link. Use NavLink for navigation menus, Link for inline links.
<NavLink
to="/dashboard"
className={({ isActive }) =>
isActive ? "nav-link nav-link--active" : "nav-link"
}
>
Dashboard
</NavLink>Q14: How do you implement protected routes in React Router v6?
function RequireAuth({ children }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <Spinner />;
if (!user) {
// Save attempted URL so we can redirect back after login
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// Usage
<Route
path="/dashboard"
element={<RequireAuth><Dashboard /></RequireAuth>}
/>State Management
Q15: What is the difference between local state, lifted state, context state, and server state?
| Type | Tool | Use Case | |---|---|---| | Local state | useState/useReducer | UI state used only by this component | | Lifted state | useState in parent | Shared between sibling components | | Context state | React Context | App-wide: theme, auth, locale | | Client state | Redux/Zustand | Complex UI state shared across many components | | Server state | React Query/RTK Query | Data from the server — loading, caching, syncing |
Server state is separate from UI state. Fetched data lives on the server; your app has a cache of it. React Query manages this cache automatically.
Q16: What is the purpose of Redux's immutability requirement?
Redux uses reference equality (===) to check if state changed. If you mutate an object in place, the reference stays the same — Redux and React think nothing changed, skip the re-render, and your UI is wrong.
Redux Toolkit uses Immer to allow "mutating" syntax in reducers while producing immutable updates under the hood:
// RTK with Immer — looks like mutation, but it's not
addItem: (state, action) => {
state.items.push(action.payload); // Immer intercepts and creates new array
}Testing
Q17: Why should you prefer getByRole over getByTestId?
getByRole queries the accessibility tree — the same way screen readers interact with your app. Tests written with getByRole verify that your UI is accessible. They also survive refactors: if you change a <button> to a styled <div role="button">, getByRole("button") still finds it.
getByTestId is brittle — it's tied to implementation details (data-testid attributes) that users and screen readers don't care about. Use it only as a last resort.
Q18: What does act() do in React tests?
act() ensures React processes all pending state updates, effects, and re-renders before you make assertions. RTL's render, userEvent, and fireEvent automatically wrap in act(). You only need to call it manually when directly triggering state updates outside of RTL utilities.
// RTL handles act() for you
await userEvent.click(button); // Internally wrapped in act()
expect(screen.getByText("Updated")).toBeInTheDocument();
// Manual act() needed when:
act(() => {
result.current.increment(); // Directly calling a hook function
});
expect(result.current.count).toBe(1);Q19: What is the difference between findBy and getBy queries?
getBy*— synchronous, throws if not found. For elements that should already exist.queryBy*— synchronous, returns null if not found. For asserting absence.findBy*— asynchronous (Promise), waits up to 1 second. For elements that appear after async operations.
// Data loads after a fetch — must use findBy
const userName = await screen.findByText("Alice");
// Assert something is absent — use queryBy
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
// Assert something exists synchronously — use getBy
const button = screen.getByRole("button", { name: "Submit" });TypeScript
Q20: How do you type a component that accepts any variant of an HTML element's props?
// Extend the HTML element's native props
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary";
loading?: boolean;
}
// Now ButtonProps has: onClick, disabled, type, form, aria-*, data-*, AND your custom props
function Button({ variant = "primary", loading, children, ...rest }: ButtonProps) {
return (
<button {...rest} disabled={loading || rest.disabled}>
{loading ? <Spinner /> : children}
</button>
);
}What Mid-Level Interviewers Really Want
They probe for tradeoffs. Every question about "should I use X or Y" should end with "it depends on..." and a clear explanation of what it depends on.
Red flags for mid-level candidates:
- "Always use Redux" / "Never use Redux" — context-free absolutes
- Not knowing the dependency array rules for useEffect
- Can't explain why
React.memowith an unstable callback prop doesn't help - Testing by checking state values instead of user-visible behavior
- "useCallback prevents re-renders" — incomplete answer (only with React.memo on the child)