React interviews probe your understanding of the component model, hooks, and rendering behavior — not just syntax. These are the questions that come up across junior to senior frontend rounds.
The Virtual DOM is an in-memory, lightweight representation of the actual DOM as a tree of JavaScript objects. When a component's state or props change, React re-renders the affected subtree, builds a new virtual tree for it, and diffs it against the previous one (reconciliation) to compute the minimal set of real DOM mutations needed. This avoids expensive direct DOM manipulation on every update, since batch-applying a small diff is far cheaper than re-rendering the whole page. Under the hood, React's Fiber architecture breaks this reconciliation work into units that can be paused, prioritized, and resumed, which is what enables concurrent features like transitions and Suspense without blocking the main thread.
Props are read-only data passed from a parent component into a child — the child can't modify them directly, though it can pass a state-updater function down as a prop to let the child trigger changes in the parent. State is data owned and managed within a component itself, and updating it (via useState or setState) triggers a re-render. Props flow one-directionally down the tree; state is local to the component that declares it unless it's explicitly lifted up to a common ancestor or passed back down as props.
useEffect runs a side effect (data fetching, subscriptions, manual DOM updates) after the browser has painted. The dependency array controls when it re-runs: omitting it runs the effect after every render, an empty array runs it once on mount, and listing specific values re-runs it whenever any of those values change between renders. Returning a cleanup function from the effect handles teardown (e.g., unsubscribing) before the next run or on unmount. In React 18's StrictMode (development only), React intentionally mounts, cleans up, and remounts effects once extra on initial mount to help catch missing or incorrect cleanup logic — that's expected behavior, not a bug.
Keys give React a stable identity for each item in a list across renders, so it can correctly match old elements to new ones during reconciliation instead of tearing down and rebuilding the entire list. Using array index as a key is risky when items are reordered, inserted, or removed, because the index-to-item mapping shifts — this can cause state, like an input's value or a component's internal state, to attach to the wrong item. Prefer a stable, unique ID from your data; the key only needs to be unique among sibling elements, not globally across the whole app.
A controlled component has its form value driven entirely by React state — the input's value prop is set from state and updated via onChange, making state the single source of truth. An uncontrolled component keeps its own internal DOM state, and you read the current value only when needed (e.g., via a ref), similar to plain HTML forms. Controlled components give more predictable validation and conditional logic since every keystroke is visible to your code; uncontrolled components involve less boilerplate and can be more performant for simple forms where you don't need to react to every change. Many real-world forms mix the two, or hand the whole thing off to a library like React Hook Form, which uses uncontrolled inputs internally for performance while still exposing a controlled-feeling API.
Both are memoization hooks that skip recomputation across renders unless their dependency array changes. useMemo memoizes a computed value (e.g., an expensive filter/sort result), while useCallback memoizes a function reference itself so it doesn't get recreated on every render — useful when passing callbacks to memoized child components (React.memo) or as effect dependencies, where a new function reference each render would otherwise cause unnecessary re-renders or effect re-runs. Neither should be used purely as a correctness mechanism: React doesn't guarantee a memoized value will be preserved forever, since it may discard cached results to reclaim memory, so relying on useMemo to prevent a value from ever recomputing is a bug waiting to happen. Reach for these after noticing an actual, measurable re-render or recomputation cost — overusing them adds complexity and its own overhead for little benefit on cheap components.
Context lets you share data (theme, auth user, locale) across a component tree without manually threading props through every intermediate level ('prop drilling'). A Provider supplies a value at the top, and any descendant can read it via useContext regardless of nesting depth. It's best for relatively static, broadly-needed data — for frequently-changing or complex state, a dedicated state manager (Redux, Zustand, Jotai) usually scales better, since every component consuming a context re-renders whenever that context's value changes, even if the consumer only cares about part of it. A common mitigation is memoizing the value object passed to the Provider and splitting one large context into several smaller, more targeted ones.
Common causes: a parent re-rendering and passing new object/array/function literals as props each time (breaking reference equality even when the underlying data is unchanged), state living higher in the tree than it needs to, or a context value changing frequently and re-rendering every consumer. Mitigations include React.memo for pure functional components, useMemo/useCallback to stabilize references, moving state closer to where it's used, and splitting context into smaller, more targeted providers. It's worth remembering that a re-render isn't inherently expensive — React still diffs against the previous virtual DOM and only touches the real DOM where something actually changed — so these optimizations matter most for components that are either genuinely expensive to render or re-render very frequently.
A custom hook is just a JavaScript function whose name starts with 'use' and that calls other hooks internally, letting you extract and reuse stateful logic across components without duplicating code or resorting to older patterns like render props or higher-order components. For example, a useFetch(url) hook could encapsulate loading/error/data state and the effect that fetches it, so any component can call it and get the same behavior. Custom hooks don't share state between the components that use them — each call site gets its own independent instance of the state and effects, they only share the logic. You should reach for one when you notice the same combination of useState, useEffect, or useRef logic repeated across multiple components, or when a piece of logic is complex enough that extracting it improves readability and testability.
useRef returns a mutable object with a single .current property that persists across renders without triggering a re-render when it changes, unlike useState. It's commonly used to hold a reference to a DOM node, for example to imperatively focus an input, or to store any mutable value you need to keep around between renders but that shouldn't affect what's displayed, like a previous value, a timer ID, or a render count. Because updating a ref doesn't cause a re-render, it's the wrong tool for anything that needs to show up in the UI — that's what state is for. A common gotcha is reading or writing ref.current during the render itself, which should be avoided since it can produce inconsistent behavior under concurrent rendering; refs are meant to be read and written inside effects or event handlers.
React.memo wraps a functional component so React skips re-rendering it if its props haven't changed, using a shallow comparison by default or a custom comparison function if you provide one. It's useful for components that render often, are relatively expensive to render, and tend to receive the same props between parent re-renders. It's not worth it for cheap components, since the shallow-comparison overhead can outweigh the savings, or for components that almost always receive new prop references anyway, such as inline object, array, or function literals from the parent, in which case you'd also need useMemo/useCallback on the parent side for memo to actually help. In practice, it's a targeted optimization to apply after profiling shows a real re-render cost, not a default wrapper for every component.
An error boundary is a class component that implements static getDerivedStateFromError and/or componentDidCatch, letting it catch JavaScript errors thrown during rendering anywhere in its child tree and display a fallback UI instead of crashing the whole app. There's currently no hook equivalent, so error boundaries must be class components, though you can wrap one in a small reusable component and use it declaratively like a normal component with a fallback prop. They do not catch errors in event handlers, asynchronous code such as setTimeout or promises, server-side rendering, or errors thrown in the boundary itself — those still need a regular try/catch. In practice, teams place a few strategic boundaries around independent sections of the UI, like a widget or a route, so one broken feature doesn't take down the entire page.
createPortal lets you render a component's children into a DOM node that lives outside the parent component's DOM hierarchy, while keeping it in the same place in the React component tree for purposes like context, event bubbling, and state. This is the standard way to implement modals, tooltips, and dropdowns, since they need to visually escape a parent's overflow:hidden or z-index stacking context but still behave like a normal part of your React app. Events dispatched from inside a portal still bubble up through the React tree according to component nesting, not DOM nesting, so a click inside a portal-rendered modal can still be caught by an onClick handler on a logical ancestor higher up. It's purely about where something renders in the DOM, not a way to break out of React's data flow or lifecycle.
React.lazy paired with a dynamic import lets you defer loading a component's code until it's actually needed, splitting your bundle into smaller chunks that are fetched on demand instead of all upfront. You wrap the lazy component, or a tree containing one, in a Suspense boundary with a fallback prop, which shows the fallback UI while the chunk is being fetched and swaps in the real component once it resolves. This is most commonly applied at route boundaries, where each page loads its own chunk, or around heavy, rarely-used components like a rich text editor or a charting library. Suspense composes: a single boundary can cover multiple lazy components, and nested boundaries let you show granular loading states for different parts of the page independently.
useReducer is useful when state is complex, such as an object with several sub-values that update together, or where the next state depends on carefully handling different 'action' types, because it centralizes update logic in a single reducer function instead of scattering multiple setState calls across event handlers. It also makes state transitions easier to test in isolation, since a reducer is a pure function, and easier to trace, because every update goes through one place with a named action rather than an arbitrary inline update. useState is simpler and more direct for independent, primitive pieces of state that don't have interrelated update logic. A practical signal to switch: if you find yourself calling several setters together in the same handler to keep state consistent, or duplicating that logic across handlers, a reducer usually cleans it up.
StrictMode is a development-only wrapper component that doesn't render any visible UI but opts its subtree into extra checks designed to surface bugs before they hit production. In React 18, it intentionally double-invokes component function bodies, and mounts, cleans up, and remounts effects like useEffect and useLayoutEffect on initial mount, to help you catch code that isn't resilient to being interrupted or re-run — a common issue under concurrent rendering, where React may start rendering, discard the work, and try again. It also warns about legacy APIs and unsafe lifecycle methods. None of this doubling happens in production builds, so it has zero runtime cost for end users, and if it reveals bugs, like an effect that doesn't clean up a subscription properly, that's StrictMode doing its job, not a false alarm.