React powers the frontend of most major tech companies. Interviewers test your understanding of React internals, hooks, state management, and performance — not just your ability to write JSX. This guide covers everything from junior to staff-level React interview questions.
React Fundamentals
1. How does the Virtual DOM work?
/**
* Virtual DOM: React maintains an in-memory tree of JavaScript objects
* mirroring the real DOM. On state change:
*
* 1. React creates a new virtual DOM tree (diffing)
* 2. Compares with previous virtual DOM (reconciliation)
* 3. Computes minimal set of real DOM updates
* 4. Applies only the necessary changes (commit phase)
*
* Reconciliation rules:
* - Different element types → tear down and rebuild subtree
* - Same element type → update props in place
* - Lists → use `key` prop for stable identity
*
* Fiber: React 16+ internal scheduler that makes reconciliation
* interruptible — high-priority updates (user input) can pause
* lower-priority renders (network data).
*/
// Example: keys in lists matter for reconciliation
function TodoList({ items }) {
return (
<ul>
{/* Bad: index as key — breaks when list reorders */}
{items.map((item, i) => <li key={i}>{item.text}</li>)}
{/* Good: stable ID as key */}
{items.map(item => <li key={item.id}>{item.text}</li>)}
</ul>
);
}
2. useState vs useReducer — when to use each
import { useState, useReducer, useCallback } from "react";
// useState: simple values, independent state
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// useReducer: complex state with multiple sub-values or state machines
type CartState = {
items: { id: string; qty: number; price: number }[];
coupon: string | null;
total: number;
};
type CartAction =
| { type: "ADD_ITEM"; item: CartState["items"][0] }
| { type: "REMOVE_ITEM"; id: string }
| { type: "APPLY_COUPON"; code: string }
| { type: "CLEAR" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find(i => i.id === action.item.id);
const items = existing
? state.items.map(i => i.id === action.item.id
? { ...i, qty: i.qty + 1 } : i)
: [...state.items, action.item];
return { ...state, items, total: items.reduce((s,i) => s + i.price*i.qty, 0) };
}
case "REMOVE_ITEM": {
const items = state.items.filter(i => i.id !== action.id);
return { ...state, items, total: items.reduce((s,i) => s + i.price*i.qty, 0) };
}
case "APPLY_COUPON":
return { ...state, coupon: action.code };
case "CLEAR":
return { items: [], coupon: null, total: 0 };
default:
return state;
}
}
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null, total: 0 });
const addItem = useCallback((item) => dispatch({ type: "ADD_ITEM", item }), []);
return <div>Total: ${cart.total}</div>;
}
3. useEffect — dependency array pitfalls
import { useEffect, useRef, useState } from "react";
function DataFetcher({ userId }: { userId: string }) {
const [data, setData] = useState(null);
useEffect(() => {
let cancelled = false; // Cleanup: prevent stale setState
async function fetchData() {
const result = await fetch(`/api/users/${userId}`).then(r => r.json());
if (!cancelled) setData(result);
}
fetchData();
return () => { cancelled = true; }; // Runs on unmount or userId change
}, [userId]); // Re-runs whenever userId changes
return <div>{JSON.stringify(data)}</div>;
}
// Custom hook: extract reusable async logic
function useFetch<T>(url: string) {
const [state, setState] = useState<{
data: T | null; loading: boolean; error: Error | null;
}>({ data: null, loading: true, error: null });
useEffect(() => {
let cancelled = false;
setState(s => ({ ...s, loading: true, error: null }));
fetch(url)
.then(r => r.json())
.then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
.catch(error => { if (!cancelled) setState({ data: null, loading: false, error }); });
return () => { cancelled = true; };
}, [url]);
return state;
}
// Common mistake: function in deps without useCallback
function Parent() {
const [count, setCount] = useState(0);
// Bad: new function reference every render → infinite loop
// useEffect(() => { fetchData(getData); }, [getData]);
// function getData() { return count; }
// Good: memoize with useCallback
const getData = useCallback(() => count, [count]);
useEffect(() => { /* use getData */ }, [getData]);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Performance Optimization
import { memo, useMemo, useCallback, lazy, Suspense } from "react";
// React.memo: skip re-render if props unchanged (shallow compare)
const ExpensiveRow = memo(function Row({ name, score, onClick }: {
name: string; score: number; onClick: (name: string) => void;
}) {
console.log("Row render:", name);
return <tr onClick={() => onClick(name)}><td>{name}</td><td>{score}</td></tr>;
});
// useMemo: cache expensive computation
function Leaderboard({ scores }: { scores: { name: string; score: number }[] }) {
const sorted = useMemo(
() => [...scores].sort((a, b) => b.score - a.score),
[scores] // Only re-sort when scores array changes
);
// useCallback: stable function reference for memoized children
const handleClick = useCallback((name: string) => {
console.log("Selected:", name);
}, []); // No deps = stable forever
return (
<table>
{sorted.map(s => <ExpensiveRow key={s.name} {...s} onClick={handleClick} />)}
</table>
);
}
// Code splitting: load components lazily
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
);
}
// useTransition: mark slow state updates as non-urgent
import { useTransition } from "react";
function SearchBar() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // Urgent: update input immediately
startTransition(() => {
setResults(expensiveSearch(e.target.value)); // Non-urgent: can defer
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</>
);
}
State Management Patterns
import { createContext, useContext, useReducer } from "react";
// Context + useReducer: lightweight global state (no Redux needed for simple apps)
type AuthState = { user: { id: string; name: string } | null; token: string | null };
type AuthAction = { type: "LOGIN"; user: AuthState["user"]; token: string }
| { type: "LOGOUT" };
const AuthContext = createContext<{
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
} | null>(null);
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "LOGIN": return { user: action.user, token: action.token };
case "LOGOUT": return { user: null, token: null };
default: return state;
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, { user: null, token: null });
return (
<AuthContext.Provider value={{ state, dispatch }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}
// When to use Redux/Zustand vs Context:
// Context: auth, theme, locale — low update frequency, consumed broadly
// Zustand/Redux: shopping cart, filters — high update frequency or complex selectors
Common Interview Patterns
| Question | Key Answer Points |
|---|---|
| When does a component re-render? | State change, parent re-render, context change, forceUpdate |
| useState vs useRef | useState triggers re-render; useRef persists without re-render |
| Controlled vs uncontrolled inputs | Controlled: value from state; uncontrolled: ref/DOM reads value |
| Lifting state up | Move state to closest common ancestor; pass down as props |
| Error boundaries | Class components with componentDidCatch; catch render errors in subtree |
| Strict Mode double-invoke | Dev-only: ensures effects are cleanup-safe; not a bug |
| useLayoutEffect vs useEffect | useLayoutEffect fires before browser paint; use for DOM measurements |
Frequently Asked Questions
What is the difference between useCallback and useMemo in React?
useMemo memoizes a computed value — it caches the result of a calculation and recomputes only when dependencies change. useCallback memoizes a function reference — it returns the same function object across renders unless dependencies change. Both prevent unnecessary re-renders in child components that use React.memo or depend on stable references.
When does a React component re-render?
A component re-renders when: (1) Its own state changes via useState or useReducer. (2) Its parent re-renders and passes new props. (3) A context it subscribes to changes. (4) forceUpdate is called. React.memo, useMemo, and useCallback help skip unnecessary re-renders by maintaining referential equality.
What is the React Event Loop and how does Fiber affect rendering?
React Fiber (introduced in React 16) makes rendering interruptible. Before Fiber, rendering was synchronous and could block the main thread. Fiber breaks rendering into units of work that can be paused, aborted, or resumed. High-priority updates (user input) can interrupt low-priority renders (data loading). useTransition exposes this to application code.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is the difference between useCallback and useMemo in React?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “useMemo memoizes a computed value — it caches the result of a calculation and recomputes only when dependencies change. useCallback memoizes a function reference — it returns the same function object across renders unless dependencies change. Both prevent unnecessary re-renders in child components that use React.memo or depend on stable references.”
}
},
{
“@type”: “Question”,
“name”: “When does a React component re-render?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A component re-renders when: (1) Its own state changes via useState or useReducer. (2) Its parent re-renders and passes new props. (3) A context it subscribes to changes. (4) forceUpdate is called. React.memo, useMemo, and useCallback help skip unnecessary re-renders by maintaining referential equality.”
}
},
{
“@type”: “Question”,
“name”: “What is the React Event Loop and how does Fiber affect rendering?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “React Fiber (introduced in React 16) makes rendering interruptible. Before Fiber, rendering was synchronous and could block the main thread. Fiber breaks rendering into units of work that can be paused, aborted, or resumed. High-priority updates (user input) can interrupt low-priority renders (data loading). useTransition exposes this to application code.”
}
}
]
}