React Interview Questions
High-frequency React interview questions with in-depth answers, filterable by difficulty and topic.
8 results
Fundamentals
- Open full page ↗
Short Answer
React Router v6 replaces imperative, component-managed data fetching (e.g.,
useEffect+fetchin v5) with declarative, route-centricloaderandactionfunctions that run before rendering—enabling automatic loading/error UI, SSR support, and atomic data dependencies per route.Details
In v5, data loading was entirely manual: developers fetched inside components (often in
useEffect) and managed loading/error states themselves. v6 shifts this to the route configuration level—each route can define aloader(for read operations) andaction(for mutations), both running on the server and client. These functions return resolved data (or throw errors), which is then consumed viauseLoaderData()anduseActionData(). Crucially, loaders run during navigation, before the route renders—so React Router can suspend or show pending states (via<Await>+Suspense), handle errors uniformly (viaerrorElement), and serialize data for SSR/hydration. This enforces separation of concerns, eliminates race conditions from staleuseEffectfetches, and makes routes truly self-contained data units.Example
TSX// Route config with loader & action const router = createBrowserRouter([ { path: "/posts/:id", element: <PostPage />, errorElement: <ErrorBoundary />, loader: async ({ params }) => { const res = await fetch(`/api/posts/${params.id}`); if (!res.ok) throw new Response("Post not found", { status: 404 }); return res.json(); }, action: async ({ request }) => { const formData = await request.formData(); await fetch("/api/posts", { method: "POST", body: formData }); return { success: true }; } } ]); // Component consumes pre-fetched data function PostPage() { const post = useLoaderData<typeof loader>(); // typed, guaranteed non-null const actionData = useActionData<typeof action>(); return ( <div> <h1>{post.title}</h1> {actionData?.success && <p>✓ Saved!</p>} </div> ); }Bonus
To stand out: mention how v6’s data APIs integrate with frameworks like Remix and Next.js App Router (which inspired them); highlight that
loader/actionfunctions are serializable—no closures or React state—making them safe for SSR and caching; and note advanced patterns likedefer()+<Await>for progressive loading of expensive data without blocking the entire route. Also emphasize that this model enables data-driven routing decisions: e.g., redirecting in a loader if auth fails (throw redirect("/login")).
Internals
- Open full page ↗
Short Answer
The
keyprop tells React which list items have changed, been added, or been removed—enabling stable identity across renders so the reconciler can minimize DOM mutations and retain local state (e.g., input focus, animation state) in the right components.Details
React uses an O(n) heuristic list-diffing algorithm (not full tree diffing) that relies on keys to match old and new elements. When keys are present and stable, React preserves component instances and their internal state (e.g.,
useState,useRef, uncontrolled input values). Without keys—or with unstable keys—React falls back to index-based matching, causing unnecessary unmounts/remounts and lost state. Keys must be stable, predictable, and unique among siblings—but do not need to be globally unique. They are consumed by React and never passed to the component as a prop.Common anti-patterns include:
- Using
Math.random()or timestamps → breaks stability → triggers full remounts. - Using array indices (
index) as keys when the list order changes (e.g., sorting, filtering, inserting) → misaligns state and DOM nodes. - Deriving keys from non-unique or mutable data (e.g.,
item.nameif names can duplicate or change). - Placing keys on the wrong element (e.g., on a wrapper
<div>instead of the direct mapped child)—keys must be on the immediate child returned bymap().
Example
TSX// ❌ Anti-pattern: index key with dynamic list {todos.map((todo, index) => ( <TodoItem key={index} todo={todo} /> // Breaks on reordering ))} // ✅ Correct: stable, intrinsic ID {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} /> // Preserves identity & state ))}Bonus
To stand out: mention that keys affect component identity, not just DOM reuse—so even pure functional components with
useMemooruseCallbackmay recalculate unnecessarily without proper keys. Also note that React 18+’s concurrent rendering makes unstable keys more dangerous: partial renders + key mismatches can cause subtle hydration or state corruption bugs. Pro tip: lint witheslint-plugin-react-hooks/exhaustive-depswon’t catch bad keys—butreact/jsx-keyandreact/no-array-index-keyrules will. - Using
- Open full page ↗
Short Answer
The virtual DOM is React’s optimized, immutable snapshot of the UI structure — not the actual browser DOM — which allows React to compute minimal, targeted changes (via diffing) and apply them in batches, avoiding costly, piecemeal DOM operations.
Details
When state or props change, React creates a new virtual DOM tree. It then compares (or “diffs”) this new tree against the previous one using a heuristic O(n) algorithm (not full tree diffing) that assumes:
- Two elements of different types produce entirely different subtrees;
- Developers can hint at stable identity across renders using
keyprops.
This diff yields a minimal set of mutations (e.g.,INSERT,UPDATE,DELETE). React batches these mutations and flushes them synchronously in a single commit phase — often coalescing multiplesetStatecalls into one re-render. Crucially, only the changed DOM nodes are updated in the real DOM, skipping layout thrashing and unnecessary repaints.
Example
TSXfunction Counter({ count }) { return <p>Count: {count}</p>; // Only the text node updates — not the <p> wrapper }If
countchanges from5to6, React diffs the virtual trees, detects the text content change, and issues onlytextContent = 'Count: 6'— no re-creation of the<p>element or traversal of its ancestors.Bonus
To stand out: clarify that the virtual DOM itself isn’t the performance hero — it’s the reconciliation algorithm + batching + declarative abstraction working together. Mention that modern React (18+) further optimizes with concurrent rendering (e.g., interruptible renders, time-slicing), where the virtual DOM serves as the mutable work-in-progress tree (
currentvsworkInProgress). Also note: frameworks like Svelte skip the virtual DOM entirely — so emphasize that React’s choice trades some memory overhead for predictable, developer-friendly consistency and powerful features like Suspense. - Open full page ↗
Short Answer
Hooks must be called at the top level (not inside loops, conditions, or nested functions) and in the same order on every render because React uses an internal linked list of “memory cells” tied to the call order—not names or identifiers—to preserve state and effects across renders.
Details
React maintains a hidden, per-component memory buffer (a linked list of
memoizedStatenodes) that corresponds 1:1 with each Hook call. On the first render, React creates a new node for each Hook invocation in sequence. On subsequent renders, React walks this list in the same order, assigning each Hook’s return value from its stored node. If you conditionally skipuseState()(e.g., inside anif), the call order shifts—the next Hook reads from the wrong node, causing mismatched state, stale closures, or incorrect effect dependencies. The dependency array isn’t directly responsible for the rule—but inconsistent Hook order breaks dependency tracking:useEffect’s cleanup and re-run logic depends on correct state/prop reads, which only happen if prior Hooks (likeuseStateoruseRef) resolved correctly.Example
TSXfunction BadComponent({ shouldShow }) { if (shouldShow) { const [count, setCount] = useState(0); // ✅ called } const [name, setName] = useState(''); // ❌ skipped when shouldShow is false → order shifts! useEffect(() => { /* depends on name */ }, [name]); // May read stale or undefined name }Bonus
To stand out: explain how React enforces this at runtime—it’s not just convention. React’s development build includes a call-counter (
currentlyRenderingFiber.memoizedState) and throws a precise error (“React Hook … cannot be called inside a callback”) when it detects an out-of-order call. Also mention that custom Hooks inherit these rules transitively: every Hook inside your custom Hook must follow them too—and thateslint-plugin-react-hooksstatically analyzes call order using AST traversal, catching violations before runtime. - Open full page ↗
Short Answer
React implements
useStateby associating each call with a sequentially allocated slot in the component’s fiber node (fiber.memoizedState), forming a singly linked list of hook objects—so multipleuseStatecalls create distinct, order-dependent state entries tied to the same fiber.Details
When a functional component renders, React’s reconciler sets
currentlyRenderingFiber = workInProgressFiberand initializesrenderLanesanddispatcher. EachuseState(initialState)call invokesmountState()(on first render) orupdateState()(on re-renders), which:- Reads from
currentlyRenderingFiber.memoizedState(a linked list ofHookobjects); - For mount: creates a new
HookwithmemoizedState = lazyInit ? lazyInit(initialState) : initialState,baseState = memoizedState,queue = { pending: null, dispatch: boundDispatch }, and links it to the fiber’s hook chain; - For update: walks the hook chain using
nextCurrentHook(derived fromcurrentFiber.alternate?.memoizedState) and applies queued updates viaprocessUpdateQueue; - The
dispatchfunction (a closure overqueueandlane) schedules a new render withenqueueUpdate(fiber, update, lane)and triggers reconciliation.
Crucially, React does not use closures or variable names—it uses call order to map eachuseState()to its correspondingHooknode. Violating the Rules of Hooks (e.g., calling conditionally) breaks this index alignment, causing state corruption.
Example
TSXfunction Counter() { const [count, setCount] = useState(0); // Hook #0 → fiber.memoizedState const [name, setName] = useState(''); // Hook #1 → fiber.memoizedState.next const [isOn, toggle] = useState(false); // Hook #2 → fiber.memoizedState.next.next // ... }Internally,
fiber.memoizedStatepoints to aHooknode containingcount’s state and queue; its.nextpoints to thenamehook;.next.nextpoints toisOn—all statically ordered at render time.Bonus
To stand out: explain how concurrent rendering affects this—e.g., during an interleaved render,
useStatemay read fromcurrentFiber.alternate.memoizedStateand apply updates from bothpendingqueues (current + work-in-progress). Also mention thatReactCurrentDispatcher.currentswitches betweenHooksDispatcherOnMount,HooksDispatcherOnUpdate, andHooksDispatcherOnRerender—enabling different behavior for initial mount vs. update vs. bailout. Bonus insight:useStateis not magic—it’s just a thin wrapper arounduseReducer({ type: 'init' }, initialState), revealing React’s unification of state logic under the reducer abstraction. - Reads from
Performance
- Open full page ↗
Short Answer
React.memowraps a component and skips re-rendering if its props pass a shallow equality check (Object.is) between renders — but it cannot detect deep changes in objects/arrays, nor does it treat equivalent inline functions or objects as equal across renders, often causing unexpected re-renders or missed optimizations.Details
React.memois a higher-order component that implements memoization at the component level. On each render, it compares the current and previous props using a shallow comparison: for primitives, it checks value equality; for objects/arrays/functions, it checks reference equality. If all props areObject.is-equal, React reuses the last rendered output (bailing out). Crucially, this means:{ count: 5 } !== { count: 5 }(different references → re-render)() => {} !== () => {}(new function every render → re-render)- Mutating an object prop (e.g.,
obj.x = 42) preserves the reference →React.memosees “no change” but renders stale UI.
The default comparison can be overridden with a customarePropsEqualfunction — but doing so correctly requires careful deep comparison or selective key-based checks (and carries perf overhead).
Example
TSXconst UserProfile = React.memo(({ user, onEdit }: { user: User; onEdit: () => void }) => ( <div> <h2>{user.name}</h2> <button onClick={onEdit}>Edit</button> </div> )); // ❌ Problematic usage: function Parent() { const [count, setCount] = useState(0); return ( <UserProfile user={{ id: 1, name: 'Alex' }} // new object every render onEdit={() => setCount(c => c + 1)} // new function every render /> ); } // → UserProfile re-renders every time, despite logical equivalence.Bonus
To stand out: explain when not to use
React.memo(e.g., cheap components, or when profiling shows no benefit), mention that hooks likeuseCallbackanduseMemoare often needed alongsideReact.memoto stabilize function/object props, and note thatReact.memoonly affects parent-triggered re-renders — it won’t prevent re-renders caused by local state or context changes within the memoized component. Bonus points for referencing the official React docs’ warning: “Don’t rely onmemoas a semantic guarantee — it’s a performance hint.” - Open full page ↗
Short Answer
Use
useMemowhen you need to avoid expensive value computation or object/array recreation on every render; useuseCallbackwhen you need to prevent function recreation to satisfy dependency checks (e.g., inuseEffect,React.memo, or as props to optimized children).Details
Both hooks accept a dependency array and return a cached result only if dependencies haven’t changed since the last render. Internally, React compares dependencies with
Object.is()— if all items are referentially equal, it returns the cached value/function.useMemo(fn, deps)executesfn()and caches its return value;useCallback(fn, deps)caches the function instance itself. Crucially,useCallbackis not for optimizing the function’s execution — it’s for preserving identity so downstream consumers (likeReact.memooruseEffect) don’t trigger unnecessarily due to new function references.Example
TSXconst Component = ({ items, onItemSelect }) => { // ✅ useMemo: avoids recreating filtered list & expensive object on every render const filteredItems = useMemo(() => items.filter(i => i.active), [items]); // ✅ useCallback: prevents new function reference → avoids re-render in memoized child const handleSelect = useCallback( (id) => onItemSelect(id), [onItemSelect] // critical: includes stable callback to avoid infinite loop ); return <ItemList items={filteredItems} onSelect={handleSelect} />; };Bonus
To stand out: clarify that neither hook guarantees performance gains — overuse can hurt readability and memory usage. Always measure with React DevTools Profiler first. Also note:
useCallbackis often unnecessary if the function doesn’t escape the component (e.g., inline event handlers likeonClick={() => doX()}— modern React’s compiler auto-optimizes many cases). Finally, emphasize that dependency arrays must be exhaustive and stable — missing deps cause stale closures, while unstable deps (e.g.,{}or[]inline) defeat memoization entirely.
State Management
- Open full page ↗
Short Answer
Choose Context API only for propagation of infrequently updated, cross-cutting concerns (e.g., dark mode toggle, user session) — not for high-frequency or deeply reactive global state. For anything beyond that — especially in medium-to-large apps with shared derived state, async side effects, or strict debugging requirements — adopt Zustand (lightweight, intuitive) or Redux Toolkit (structured, enterprise-ready).
Details
Context API’s main pitfalls are re-renders and bundle bloat via abstraction: every
useContextconsumer re-renders whenever any part of the context value changes — even if it only cares about one field. This forces manual memoization (useMemo,React.memo) and often leads to “context sprawl” (multiple contexts → maintenance debt). It also lacks built-in devtools, middleware, serialization, or time-travel debugging.Zustand avoids re-render overhead by letting components subscribe only to the slices they use, uses lightweight proxy-based state updates, and ships at ~1.2 kB gzipped. RTK adds opinionated structure (reducers + thunks/RTK Query), built-in immer, powerful DevTools, and seamless TypeScript inference — all while staying lean (~9 kB gzipped for core + RTK Query).
Bundle size matters: Context is “free” (built-in), but misused Context + excessive memoization can inflate JS size more than adding Zustand. Maintainability suffers when teams build ad-hoc Context wrappers to mimic middleware or persist state — reinventing wheels that Zustand/RTK solve out-of-the-box.
Example
TSX// ✅ Good Context use: rare, atomic, UI-wide config const ThemeContext = createContext<{ mode: 'light' | 'dark'; toggle: () => void } | null>(null); // ❌ Bad Context use: frequent updates + multiple subscribers needing subsets const CartContext = createContext<{ items: Item[]; total: number; addItem: (i: Item) => void } | null>(null); // → Causes unnecessary re-renders across product listings, headers, modals // → Better: Zustand store with selective `useStore(state => state.items)`Bonus
To stand out: articulate migration strategy. E.g., “We start with Context for auth/theme, then extract domain-specific stores (cart, search) into Zustand as complexity grows — and we measure re-render counts (via React DevTools profiler) before/after to validate the trade-off.” Bonus points for mentioning
useSyncExternalStore(for custom external state) or noting that RTK Query eliminates the need for separate data-fetching libraries — reducing both bundle size and cognitive load.