React interview questions

12 questions on React: components and props, state and batching, effects, keys, context, refs, memoization, error boundaries, code splitting, and stale closures.

01ReactFoundation

Components and props

Answer

  • A component is a function (or class) that returns React elements describing UI from props and state.
  • Props are read-only inputs from the parent. Treat them as immutable. Do not mutate props objects or arrays.
  • Compose small components instead of one large view. Pass data down and events up through callbacks.
  • Children are a normal prop. Use them to wrap layout or shared chrome around nested content.
  • Name components with uppercase so React treats them as components, not DOM tags.
  • Pure rendering: given the same props and state, the returned UI description should match.
  • Pitfall: mutating a prop and expecting the parent to see the change. Lift state or pass a setter callback instead.
  • Default props and destructuring keep call sites clear. Prefer explicit props over hidden globals.
  • Interview tip: explain one-way data flow from parent props into child UI.
Flow diagram: Props flow
TSX
type UserCardProps = { name: string; title: string }; export function UserCard({ name, title }: UserCardProps) { return ( <article> <h2>{name}</h2> <p>{title}</p> </article> ); }
02ReactFoundation

Lists and keys

Answer

  • key helps React match list items across renders so it can reuse, reorder, or destroy the right child instances.
  • Use a stable id from your data. Prefer database ids or UUIDs created when the item is born.
  • Avoid array index as key if the list can reorder, insert in the middle, or delete items. Wrong reuse follows the index, not the item.
  • Keys must be unique among siblings. They do not need to be globally unique across the whole app.
  • Wrong keys cause stale state in row components (inputs keep typing from a different item).
  • Keys are not passed to the component as props. Do not read props.key inside the child.
  • Pitfall: generating random keys during render. That forces remount every time and resets state.
  • After filtering or sorting, still key by the item id, not by the filtered index.
  • Interview tip: connect keys to reconciliation, not to array performance myths alone.
Flow diagram: List keys
TSX
{items.map((item) => ( <Row key={item.id} item={item} /> ))}
03ReactFoundation

Controlled inputs

Answer

  • A controlled input takes its value from React state and updates that state in onChange (or similar handlers).
  • React state is the single source of truth for the field. The DOM value mirrors state on each render.
  • Uncontrolled inputs use a ref and defaultValue when you want the DOM to own the value until you read it.
  • Controlled forms make validation, formatting, and disabling submit straightforward because state is always current.
  • For checkboxes use checked plus onChange. For multiple inputs, name fields clearly and update the matching state slice.
  • Pitfall: setting value without onChange freezes the input. Either control fully or leave it uncontrolled.
  • Mixing controlled and uncontrolled modes on the same input (switching value from undefined to a string) warns in development.
  • File inputs stay uncontrolled for the file list. Read files from the change event or a ref.
  • Prefer controlled inputs in interview demos unless the problem specifically needs uncontrolled behavior.
Flow diagram: Controlled input
TSX
const [email, setEmail] = useState(""); <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
04ReactFoundation

useRef

Answer

  • useRef returns a mutable ref object whose .current persists across renders and does not trigger a re-render when it changes.
  • Common uses: hold DOM nodes, timer ids, AbortController instances, and previous values.
  • Assign DOM refs with ref={myRef} on a host element, then read myRef.current after commit.
  • Do not read or write ref.current during render to drive UI output that must stay in sync. That bypasses React's render model.
  • Refs are fine inside event handlers and effects when you need the latest mutable box without a render.
  • forwardRef lets a parent pass a ref through to a child host node when the child is a function component.
  • Pitfall: treating a ref like state and expecting the screen to update when .current changes.
  • Callback refs run with the node on attach and null on detach. Useful when you need side effects on mount of the node.
  • Prefer state for anything visible. Prefer refs for imperative handles and mutable non-UI data.
Flow diagram: ref
TSX
const inputRef = useRef<HTMLInputElement>(null); function focus() { inputRef.current?.focus(); } <input ref={inputRef} />
05ReactFoundation

Error boundaries

Answer

  • Error boundaries catch render errors in the child tree and show fallback UI instead of unmounting the whole app.
  • They do not catch errors inside event handlers, async code, or server-side rendering by themselves. Handle those separately.
  • Implement with a class component using getDerivedStateFromError and componentDidCatch, or use a maintained library wrapper.
  • Place boundaries around independent UI regions so one widget failure does not blank the page.
  • Log the error in componentDidCatch to your monitoring tool, then show a safe retry or navigate action.
  • Pitfall: expecting a boundary to catch errors thrown in the boundary's own render without nesting another boundary above.
  • React does not yet provide a hook-only error boundary API. Class lifecycle methods remain the supported mechanism.
  • Combine with route-level boundaries in larger apps so navigation can recover cleanly.
  • Interview tip: name what boundaries catch and what they do not catch.
Flow diagram: Catch render errors in the tree
TSX
class ErrorBoundary extends React.Component< { fallback: React.ReactNode; children: React.ReactNode }, { hasError: boolean } > { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } render() { return this.state.hasError ? this.props.fallback : this.props.children; } } <ErrorBoundary fallback={<p>Something broke.</p>}> <Profile /> </ErrorBoundary>
06ReactFoundation

Code splitting

Answer

  • React.lazy(() => import('./Page')) loads a component on demand as a separate chunk via dynamic import().
  • Wrap lazy components in Suspense with a fallback while the chunk loads.
  • Split large routes so the initial bundle stays small and first paint improves.
  • Works with bundlers that understand dynamic import (webpack, Vite, and others).
  • Named exports need a thin re-export default for React.lazy, which expects a module with a default component export.
  • Pitfall: lazy-loading tiny components that cost more in request overhead than they save in parse time.
  • Error boundaries around Suspense help when a chunk fails to load (network errors).
  • Prefetch important routes on hover or idle time when product metrics justify it.
  • Interview tip: connect code splitting to route-based chunks and Suspense fallbacks, not to vague performance claims.
Flow diagram: Code split
TSX
const Reports = lazy(() => import("./Reports")); <Suspense fallback={<p>Loading...</p>}> <Reports /> </Suspense>
07ReactIntermediate

React useEffect: Synchronization and Lifecycle

Answer

  • useEffect connects a component to an external system (such as browser DOM APIs, network sockets, window event listeners, or non-React libraries). It runs after the browser paints the screen to avoid blocking visual updates.
  • Not for Derived State: Do not use useEffect to calculate state that can be derived directly during rendering. Calculating derived state inside an effect causes an unnecessary second render cycle and risks state desynchronization.
  • Cleanup Function: Returning a function from useEffect schedules cleanup to run right before the effect re-runs with new dependencies, and when the component unmounts. Essential for aborting fetches, clearing timers, and removing event listeners.
  • React 18 Strict Mode Development Behavior: In development mode, React deliberately mounts, unmounts, and re-mounts every component to verify that effect cleanups are correctly implemented and do not cause resource leaks.
Flow diagram: useEffect execution and cleanup cycle across renders
TypeScript (React 18+)
import { useState, useEffect } from 'react'; interface UserProfileProps { userId: string; } export function UserProfile({ userId }: UserProfileProps) { const [user, setUser] = useState<User | null>(null); const [error, setError] = useState<Error | null>(null); useEffect(() => { const controller = new AbortController(); async function loadUser() { try { const res = await fetch(`/api/users/${userId}`, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setUser(data); } catch (err: any) { if (err.name !== 'AbortError') setError(err); } } loadUser(); // Cleanup runs before next effect execution or upon component unmount return () => controller.abort(); }, [userId]); if (error) return <div>Error loading user: {error.message}</div>; if (!user) return <div>Loading...</div>; return <h1>{user.name}</h1>; }

Dependencies and Stale Closures

  • Exhaustive Dependencies Rule: Every reactive value (props, state, or functions declared in the component body) read inside an effect must be included in the dependency array.
  • Stale Closure Risk: Omitting dependencies causes the effect closure to capture outdated prop and state values from older renders, leading to bugs like timers ticking with frozen numbers.
08ReactIntermediate

Context

Answer

  • Context passes data through the tree without threading props through every intermediate component.
  • Create a context, wrap a subtree in Provider, and read with useContext (or Context.Consumer in older code).
  • All consumers re-render when the provider value changes. Value identity matters.
  • Split contexts by concern (theme, auth, locale) so unrelated updates do not refresh the whole tree.
  • Memoize the provider value object when you pass an object or callbacks and updates are frequent.
  • Prefer props for local parent-child data. Use context for cross-cutting values many levels deep.
  • Pitfall: putting the entire app state in one context and updating often, which re-renders large subtrees.
  • Default context values apply only when no Provider sits above the consumer. Do not rely on defaults for required auth data.
  • Interview tip: contrast context with prop drilling and with a state library when the update graph is complex.
Flow diagram: Context
TSX
const AuthContext = createContext<Auth | null>(null); export function useAuth() { const value = useContext(AuthContext); if (!value) throw new Error("useAuth needs AuthProvider"); return value; }
09ReactIntermediate

useMemo and useCallback

Answer

  • useMemo caches a computed value between renders until its dependency list changes.
  • useCallback caches a function identity for child memoization, effect dependencies, or stable event subscriptions.
  • React.memo wraps a component so it skips re-render when props are shallowly equal.
  • Measure first. Wrapping everything adds memory, dependency bookkeeping, and complexity.
  • Useful when passing callbacks to memoized children or building expensive derived data from large lists.
  • Pitfall: new object or array props ({...}) break memoization even when contents look the same.
  • Dependencies must be correct. Wrong deps return stale memoized values, the same class of bug as stale effects.
  • In many apps, lifting state and splitting components removes the need for heavy memoization.
  • Follow the official guidance: memoize for proven costs, not by default on every function.
Flow diagram: memo
TSX
const total = useMemo( () => lines.reduce((sum, l) => sum + l.price, 0), [lines], ); const onSave = useCallback(() => save(orderId), [orderId]);
10ReactAdvanced

React State, Fiber Architecture, and Automatic Batching

Answer

  • State in React represents values that change over time and trigger a component re-render when updated.
  • Immutability Requirement: React relies on shallow object reference equality (Object.is) during reconciliation to determine whether state has changed. Mutating state objects or arrays in-place (such as state.items.push(newItem)) does not change the memory reference of the array, causing React to miss the change and skip the re-render. Always treat state as read-only and return new copies using the spread operator or immutable update patterns.
  • React 18 Automatic Batching: In React 18, all state updates are automatically batched together into a single re-render, whether they originate inside React event handlers, asynchronous fetch promises, setTimeout callbacks, or native event listeners.
  • Fiber Tree Reconciliation: React maintains an in-memory virtual tree of Fiber nodes representing component instances, DOM nodes, and state queues. When state updates, React constructs a work-in-progress Fiber tree, computes minimal DOM diffs, and commits the updates to the real browser DOM in a single flush.
Flow diagram: State mutation failure vs immutable copy reconciliation
TypeScript (React 18 Immutable State Patterns)
import { useState } from 'react'; interface Task { id: number; title: string; completed: boolean; } export function TaskList() { const [tasks, setTasks] = useState<Task[]>([]); const addTask = (title: string) => { // Create a new array reference with spread operator setTasks(prevTasks => [ ...prevTasks, { id: Date.now(), title, completed: false } ]); }; const toggleTask = (id: number) => { // Map returns a new array, updating only the target object copy setTasks(prevTasks => prevTasks.map(t => (t.id === id ? { ...t, completed: !t.completed } : t)) ); }; return ( <ul> {tasks.map(t => ( <li key={t.id} onClick={() => toggleTask(t.id)}> {t.completed ? <s>{t.title}</s> : t.title} </li> ))} </ul> ); }
11ReactAdvanced

Stale Closures in React Hooks

Answer

  • A closure in JavaScript captures variables from its lexical environment at the time of creation. In React, a function component re-executes on every render, creating a fresh scope with current props and state.
  • How Closures Become Stale: When an asynchronous callback (such as setTimeout, setInterval, or a Promise continuation) captures a state variable, it holds a reference to the variable instance from that specific render. If the component re-renders and state updates, the older callback continues referencing the outdated value from the render in which it was created.
Flow diagram: Closure capturing render scope vs mutable ref
TypeScript (Fixing Stale Closures)
import { useState, useEffect, useRef } from 'react'; export function Timer() { const [count, setCount] = useState(0); // Solution 1: Functional update (Preferred for state updates) useEffect(() => { const id = setInterval(() => { setCount(prevCount => prevCount + 1); // Always reads latest queued state }, 1000); return () => clearInterval(id); }, []); // Empty deps safe because no closure over count is required // Solution 2: useRef for callbacks needing latest values without re-running effect const countRef = useRef(count); countRef.current = count; useEffect(() => { const id = setInterval(() => { console.log("Latest count:", countRef.current); }, 1000); return () => clearInterval(id); }, []); return <div>Count: {count}</div>; }

Solutions: Functional State Updates and useRef

  • Functional State Updates: When updating state based on previous state, pass an updater lambda: setCount(prev => prev + 1). This bypasses the closure value and receives the current state value directly from React's internal state queue.
  • useRef Escape Hatch: Unlike state, ref objects hold a mutable .current property that maintains the exact same object reference across all renders. Reading ref.current inside an asynchronous callback always accesses the latest value.
12ReactAdvanced

Advanced: index as key

Answer

  • key={index} is safe only when the list is static: no reorder, no insert in the middle, no delete of earlier items.
  • When the list changes shape, React can reuse the wrong component instance because the key stayed with the index, not the item.
  • Local state inside a row (controlled inputs, open flags) then appears on another row after insert or delete.
  • Use a stable id from your data. Create ids when the item is born, not during each render.
  • After filtering, still key by the item id, not by the filtered index.
  • If your API has no id, generate one when the item enters client state and keep it on the object.
  • Pitfall: using index keys in sortable to-do lists or tables, which are exactly the cases that break.
  • Indexes as keys can look fine in demos until the first reorder. Test with insert at index 0.
  • This is the same reconciliation rule as react-lists-keys, focused on the common interview failure mode.
Flow diagram: Index key remaps identity
TSX
// Bad when list can reorder {items.map((item, i) => <Row key={i} item={item} />)} // Good {items.map((item) => <Row key={item.id} item={item} />)}
Sources and further reading