React Developer Interview Questions · 2026

React Developer Interview Questions (2026): Hooks, Performance & Patterns

React was used by 46.9 percent of professional developers in the 2025 Stack Overflow Developer Survey, more than double Angular's 19.8 percent and Vue's 18.4 percent (Stack Overflow, 2025). That volume shows up directly in hiring: React developer interview questions have gotten sharper and more specific because of it. Almost every mid-size and large product team hiring a frontend engineer in 2026 runs at least one round built entirely around hooks, rendering behavior, and component design.

Here's an opinion that might be wrong: I think most React interview prep still spends too much time on "what's the difference between props and state" and not nearly enough on debugging a broken useEffect. The syntax questions are five-minute warm-ups. The questions that actually separate a mid-level candidate from a senior one are about closures, re-renders, and reconciliation, the stuff that only bites you once your app has more than one component talking to another.

This page covers the React developer interview questions that show up across real loops in 2026, from junior screens to senior onsites. It's organized by topic rather than by company, because unlike a single company's interview loop, React questions repeat across employers almost verbatim once you've seen the pattern. Function components and hooks are the default everywhere now; class components barely come up except as a "have you seen this before" gotcha. Server Components get a brief mention near the end, because more teams are shipping them in 2026, but the bulk of a React interview is still about the client-side rendering model.

52Questions
Hooks & RenderingCore Topic
Live in-browser codingFormat
18/19 + RSC-awareReact Baseline

Components, props, and state

Every loop starts here, even for senior candidates. It's a warm-up, but a sloppy answer sets a bad tone for the rest of the interview, and interviewers do notice.

Easy questions

15

Props are read-only data a parent passes down to a child; the child can't change them, only the parent that owns them can. State is data a component owns and manages itself, and updating it triggers a re-render of that component and everything below it.

Interviewers use this to check whether a candidate conflates "data flowing through a component" with "data a component controls." A candidate who says "they're basically the same thing, just used in different places" is going to struggle two questions later on why lifting state up matters.

React doesn't update the DOM immediately. It schedules the update, batches it with any other state updates fired from the same event, and re-renders once, not once per setter call.

Since React 18, this batching also applies inside promises, timeouts, and native DOM event handlers, not just React's own synthetic event system. That's a quieter change than hooks or concurrent rendering got, but it trips up candidates who learned React pre-18 and expect setState calls outside a React event handler to trigger separate renders.

useState triggers a re-render whenever you update it. useRef doesn't, mutating .current is invisible to React's render cycle entirely.

Use state for anything the UI needs to reflect. Use a ref for things like a DOM node reference, a previous-value tracker, or a timer ID, values you need to keep around but don't want to redraw the screen for every time they change.

Only call Hooks at the top level, never inside loops, conditions, or nested functions. And only call them from React function components or from other custom Hooks.

Both rules exist because React relies on call order to match each Hook invocation to its stored state between renders. There's no Hook "name" being tracked internally, just position in a list, which is also why the linter can catch violations statically without running your code.

Keys give React a stable identity for each item across renders. Without one, or with the wrong one, React falls back to matching items by position, so inserting a new item at the front of a list can make React think every existing item changed, and re-render or re-mount things that shouldn't have been touched at all.

React won't crash without keys, it'll just log a console warning, but any per-item local state or an uncontrolled input's value can end up attached to the wrong item once the list reorders.

A re-render means React calls your component function again and diffs the output, it doesn't necessarily touch the real DOM at all if nothing changed. A re-paint is the browser actually redrawing pixels, which only happens if the diff produced real DOM mutations.

A re-mount is the expensive one: the component instance is destroyed and a fresh one created, local state is lost, cleanup effects run, and the mount lifecycle runs again from the top.

A controlled input's value comes from React state, value={state} plus an onChange handler that updates it, so React is the single source of truth and every keystroke re-renders the component.

An uncontrolled input keeps its value in the DOM itself. You read it later through a ref, or off a submit event, and React never re-renders on every keystroke because it isn't watching the field.

Fragments let you group a list of children without adding an extra node to the DOM. When a component needs to return multiple elements, JSX requires a single root, and wrapping everything in a div can break layouts that depend on direct parent-child CSS relationships, flex or grid, or table markup where a stray div between tr and td is invalid HTML. React.Fragment, or the shorthand empty tags, solves that by rendering the children directly with no wrapper in the output.

The one case where you need the longer React.Fragment form instead of the shorthand is when you're mapping over a list and need to pass a key, since the shorthand form doesn't accept props.

useId generates a unique, stable string ID for a component instance, mainly for wiring up accessibility attributes like matching a label's htmlFor to an input's id, or aria-describedby to a hint element. Before this hook, people either hardcoded IDs, which breaks the moment a component renders twice on the same page, or rolled their own counter or uuid logic, which works fine on the client but produces different values on the server and client during SSR, causing hydration mismatches.

useId is specifically designed to stay consistent across server and client render passes, which is why it exists as a first-class hook rather than a utility library function. It's not meant for keys in a list.

You attach a ref via useRef and pass it to the ref prop of the element, const inputRef = useRef(null), then input ref={inputRef}. After the component mounts, inputRef.current holds the actual DOM node, so you can call imperative APIs on it: focus, scrollIntoView, measuring getBoundingClientRect, or integrating a non-React library that expects a real element.

It's appropriate when you need something the declarative render model can't express, focus management, media playback control, text selection, or reading layout after paint. It's not appropriate for anything you could instead derive from state and render normally. Reaching into the DOM to read a value React already tracks is usually a sign you should be using state or props instead.

The most direct fix is composition. Restructure so the component that needs the data renders the deep child directly, passing it as children or a prop, instead of threading data down through components that only pass it along. That often removes the need for anything fancier.

If composition doesn't fit the shape of the tree, Context is the built-in answer. One provider near the top, and any descendant can read it with useContext without every intermediate component needing to know it exists. For state that many unrelated parts of the app read and write, a state library like Zustand or Redux is usually a better fit than Context, since Context re-renders every subscriber on any value change unless you split it carefully.

In development, StrictMode intentionally mounts, unmounts, and remounts every component once, and calls render functions, state initializers, and effect setup and cleanup pairs twice, to surface side effects that aren't properly cleaned up. If your effect subscribes to something in its setup and doesn't tear it down in cleanup, running it twice makes the bug obvious immediately instead of showing up as a subtle leak in production.

This only happens in development, not in production builds, so it never affects real users. The fix is almost never to prevent the double call. It's to make sure your effects and reducers are pure and idempotent, which is exactly the property StrictMode checks for.

useState(expensiveComputation()) runs expensiveComputation on every single render, even though the result only matters on the very first one, because the value is only used to seed the state and gets thrown away after that. Passing a function instead, useState(() => expensiveComputation()), tells React to call that function only once, during initial mount, and use its return value as the starting state.

Use this whenever the initial value requires real work: parsing a large JSON blob from localStorage, building an initial Map from props, or any calculation that isn't just a literal or a cheap prop reference. For something like useState(0) there's no benefit, since reading a value is already cheap. The lazy form only pays off when the computation itself is expensive.

Instead of one state variable per field, keep a single state object that mirrors the form's shape, and write one generic change handler that updates whichever field changed by reading e.target.name.

jsx
const [form, setForm] = useState({ name: "", email: "", company: "" });

function handleChange(e) {
  const { name, value } = e.target;
  setForm(prev => ({ ...prev, [name]: value }));
}

<input name="email" value={form.email} onChange={handleChange} />

This scales to any number of fields without new state declarations, and it keeps validation logic in one place since you're working against one object. For genuinely large forms with nested fields or per-field validation timing, a library like React Hook Form or Formik removes a lot of the boilerplate and avoids re-rendering the whole form on every keystroke, since it manages inputs as uncontrolled refs internally.

A portal, created with createPortal(children, domNode), renders a component's output into a DOM node outside its parent's DOM hierarchy, while keeping it in the same place in the React tree for purposes of context, event bubbling, and state. The component still behaves like a normal child as far as React is concerned. It just physically lives somewhere else in the page.

Modals, tooltips, and dropdown menus are the classic use case, because they need to visually escape a parent that might have overflow hidden or a low z-index stacking context, but they still need to receive context from providers higher up the tree and have their click events bubble up naturally to close-on-outside-click handlers.

Medium questions

25

React tracks Hooks by call order, not by name. On every render it walks the list of Hook calls in the exact sequence they were declared and matches each one to its stored slot of state. Wrap a useState call in an if, and that order can shift between renders, so React attaches the wrong stored value to the wrong Hook.

That's the whole reason behind the Rules of Hooks: call Hooks only at the top level, and only from component functions or other Hooks. The eslint-plugin-react-hooks exhaustive-deps rule catches most violations before they ship, but interviewers still expect you to explain the mechanism, not just recite the rule.

The effect reads id, so id belongs in the dependency array. With an empty array, the effect only runs once on mount, so if id changes afterward the fetch never re-fires and the component keeps showing data for whatever id was there the first time.

Fix: add id to the array so the effect re-runs when it changes. If exhaustive-deps is configured, it flags this immediately, and that's exactly the point (React docs, Synchronizing with Effects). Candidates who "fix" the warning by disabling the lint rule instead of adding the dependency are showing a real gap, not a shortcut.

useMemo memoizes a computed value. useCallback memoizes a function reference, and under the hood it's basically useMemo(() => fn, deps) with nicer syntax.

Neither is free. Both cost you a dependency-array comparison on every render, so wrapping a cheap calculation in useMemo can genuinely make things slower, not faster. They pay off when the memoized thing is expensive to recompute, a sort over a large array, say, or when a stable reference matters, like a callback passed to a child wrapped in React.memo, where a fresh function every render would quietly defeat that memoization. I'll say something that might get pushback: most useCallback calls in real production codebases are cargo-culted and don't change anything measurable. Profile before reaching for it, don't reach for it by reflex.

A custom Hook is any function whose name starts with use and that calls other Hooks internally, think useLocalStorage, useDebouncedValue, useFetch.

The naming convention isn't decorative. React DevTools and the linter both use the use prefix to know a function is expected to follow the Rules of Hooks. What a custom Hook actually buys you is the ability to extract stateful logic, not just plain computation, and reuse it across components without copy-pasting the useState and useEffect wiring into each one.

It's a lightweight in-memory tree of plain JavaScript objects that mirrors the structure of the real DOM. When state changes, React builds a new virtual tree, diffs it against the previous one (that diffing step is what "reconciliation" refers to), and computes the smallest set of real DOM mutations needed, then applies only those.

Direct DOM manipulation is comparatively expensive, layout, paint, and reflow all cost real time, so batching and minimizing actual DOM writes is the win. The virtual DOM itself isn't magically fast, it's just cheap to build and diff compared to touching the real thing repeatedly.

React compares elements by type first. Same type at the same position in the tree, React updates the existing node's props in place and recurses into its children. Different type, a <div> swapped for a <span>, or one component type swapped for another, React unmounts the whole subtree and builds a fresh one from scratch.

That full unmount also means losing any local state and running cleanup effects, which is exactly why conditionally rendering two different component types at the same JSX position can make an input silently lose focus or a form lose whatever the user had typed.

Yes, if the list is static: never reordered, never filtered, and never has items inserted or removed from the middle. For anything else, it's a foot-gun.

I've seen more production bugs from index-as-key on filterable or sortable lists than from almost any other single React mistake. It's an easy thing to get right (pull a stable id off your data) and an easy thing to skip under a deadline, which is exactly why interviewers keep asking it.

Context solves prop drilling, passing a value like theme, locale, or the current user down through many layers of components that don't otherwise need it.

It's a poor fit for state that changes often and is read by many consumers, because every consumer of a context re-renders whenever that context's value changes. There's no built-in way to subscribe to just a slice of it. A theme toggle that flips twice a session is fine in context. A shopping cart total that updates on every click is a worse fit without extra work.

Split the context in two: one for the fast-changing value, one for the rarely-changing dispatch function or setter, since setter references from useState are stable across renders.

Also worth memoizing the context value object itself. An inline object literal passed as the value prop is a new reference every render, which defeats consumer memoization even when the underlying data didn't actually change. Some teams pair useReducer with context to centralize update logic, then wrap the provider's value in useMemo.

React.memo wraps a component and skips re-rendering it when its props are shallowly equal to the previous render's props. It does nothing for state changes inside the component itself.

It also doesn't help if you're passing a new object, array, or function reference as a prop on every render, an inline arrow function or object literal is never === to the one from the previous render. Wrapping a component in memo and then not stabilizing the props feeding it with useMemo or useCallback is one of the most common "why isn't memo doing anything" bugs.

Performance, mostly. On a form with dozens of fields, a fully controlled setup re-renders the entire form component on every keystroke in every field, unless each field is split into its own component.

An uncontrolled field, or a library like react-hook-form that uses refs under the hood and only re-renders on submit or validation, sidesteps that entirely. The trade-off: you lose the ability to validate or transform the value live as the user types, since React isn't watching every keystroke happen.

A new function gets created on every render. If that row is wrapped in React.memo and onClick is one of its props, the memo comparison fails every single time because the reference is never the same as last render, so the "optimization" does nothing.

In a small app this genuinely isn't worth worrying about, function allocation is cheap and the engine handles it fine. It starts to matter once you've got hundreds of memoized rows and profiling shows memo isn't preventing re-renders. That's when useCallback, or restructuring the handler to read an id off a data attribute at the list level instead of per row, is worth the added complexity.

useReducer earns its keep once state updates stop being simple assignments and start being transitions, when the next state depends on the current state in a nontrivial way, or when several pieces of state always change together in response to the same action. A shopping cart is a good example: adding an item, removing one, and applying a coupon all touch the same object, and writing that as three or four separate useState calls means scattering the same logic across multiple handlers and risking them drifting out of sync.

With a reducer, all the transition logic lives in one function you can test in isolation, without rendering anything. You dispatch an action and assert on the returned state. It also reads better when a component has many possible actions, since the JSX just calls dispatch and the reducer function documents every state transition the component supports in one place.

useState is still the better default for anything that's just a value that changes, a toggle, a text input, a loading flag. Reaching for useReducer everywhere adds indirection without buying you anything.

Both run after render, but at different points relative to the browser painting the screen. useEffect fires asynchronously after the browser has painted, so if your effect changes something visual, the user briefly sees the old version before it updates. useLayoutEffect fires synchronously after React has updated the DOM but before the browser paints, so any DOM reads or writes you do there are reflected in the very first paint the user sees.

This matters for things like measuring an element's size and then repositioning something based on that measurement, a tooltip that needs to flip sides if it would render off-screen, for instance. If you measure and reposition in useEffect, users see a flash where it renders in the wrong spot and then jumps. Doing the same work in useLayoutEffect avoids the flash because it happens before paint.

The tradeoff is that useLayoutEffect blocks the browser from painting until it finishes, so heavy work there can make the page feel janky. Default to useEffect and only switch to useLayoutEffect when you have a visible flicker to fix.

forwardRef lets a component receive a ref from its parent and attach it wherever it wants internally, which by itself just forwards the raw input element. useImperativeHandle goes a step further and lets you customize exactly what the parent's ref.current sees, instead of the actual DOM node.

jsx
const FancyInput = forwardRef(function FancyInput(props, ref) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ""; }
  }));

  return <input ref={inputRef} {...props} />;
});

Now a parent holding a ref to FancyInput can call ref.current.focus() or ref.current.clear(), a small, deliberate API, instead of poking directly at the DOM node and depending on its internal markup staying the same. It's most useful for reusable component libraries where you want to hide implementation details but still allow imperative actions like focusing or scrolling.

An Error Boundary is a class component that implements static getDerivedStateFromError, to update state and render a fallback UI, and usually componentDidCatch, to log the error. React walks up the tree from wherever a render, lifecycle method, or constructor throws and finds the nearest boundary above it, then renders that boundary's fallback instead of the broken subtree.

There's no hook equivalent because there's no way to catch an error thrown during another component's render from inside a hook. Hooks only run in the component that calls them, and the whole point of an Error Boundary is catching failures in its children. That's a genuine gap that libraries like react-error-boundary fill by wrapping the class component boilerplate for you.

It's worth knowing what Error Boundaries don't catch: errors in event handlers, in async code, in server-side rendering, and errors thrown in the boundary itself. Those need regular try and catch or handling at the call site.

React.lazy tells the bundler, Vite or webpack, to put a component and everything it imports into its own chunk file instead of the main bundle, and only fetch that file when the component is first rendered. Wrapping it in a Suspense boundary with a fallback tells React what to show while that import promise is still pending.

Suspense works by the lazy component throwing a promise during render, which React catches, and it renders the nearest fallback instead of crashing. Once the promise resolves, React retries the render, now with the real component available. The same mechanism now extends beyond code splitting. Data-fetching libraries and frameworks like Next.js use Suspense to show a fallback while a component's data is loading too, not just its code.

The practical payoff is a smaller initial bundle for routes or heavy widgets users may never visit in a given session, like a settings page or an admin panel, at the cost of a short loading flash the first time they do visit it.

Both exist to let you mark an update as low priority so React can keep the UI responsive while that update happens in the background. Normally every state update is treated the same, urgent, but sometimes one update is urgent, like showing what the user just typed, and another is expensive and can afford to lag slightly behind, like re-filtering a 10,000-row list based on that same input.

useTransition wraps the expensive update itself inside startTransition, and gives you an isPending flag so you can show a subtle loading indicator. React will interrupt that low-priority render if something urgent comes in, like another keystroke, instead of letting it block typing.

useDeferredValue takes a different angle on the same problem. You defer a value rather than a setter call, then render your expensive list using the deferred value. React lets the input update immediately with the real value, and lazily updates the list once it can, using the same interruption behavior. Use useTransition when you control the state update. Use useDeferredValue when you're deriving something from a value you don't own, like a prop.

If the id changes before the first fetch finishes, you can end up with two requests in flight, and there's no guarantee the older request resolves first. If it resolves after the newer one, its stale data overwrites the correct, more recent data on screen, and the user sees the wrong profile even though they clicked away from it already.

The fix is to use the effect's cleanup function to ignore results from a fetch that's no longer relevant.

jsx
useEffect(() => {
  let ignore = false;

  fetchProfile(id).then(data => {
    if (!ignore) setProfile(data);
  });

  return () => { ignore = true; };
}, [id]);

Every time id changes, React runs the cleanup from the previous effect run before the new one, flipping ignore to true for the stale request, so its callback becomes a no-op even after it eventually resolves. This is the standard pattern for guarding against race conditions in effects, separate from AbortController, which additionally cancels the network request itself rather than just ignoring its result.

AbortController gives you a signal you can pass to fetch, and calling controller.abort() causes the request to actually stop, rather than just having your code ignore the eventual result.

ts
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/profile/${id}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setProfile)
    .catch(err => {
      if (err.name !== "AbortError") console.error(err);
    });

  return () => controller.abort();
}, [id]);

This is a meaningful step up from the ignore-flag pattern because it stops wasted bandwidth and server load for requests nobody needs anymore, which matters more for expensive queries or when users navigate quickly. The one gotcha is remembering to filter out AbortError in your catch block, otherwise a normal, expected cancellation gets logged as if it were a real failure.

Historically React attached a single listener per event type at the document root, in React 17 and later it's the root container instead, and used event bubbling to figure out which component's handler should fire, rather than attaching a separate native listener to every button and input on the page. That's cheaper on memory and setup cost when you have a large tree, and it made cleanup simpler since there was only ever one native listener to manage per event type.

The object your handler receives is a SyntheticEvent, a wrapper around the native browser event that normalizes differences between browsers so preventDefault, stopPropagation, and properties like target behave consistently across engines. In modern React it's mostly a thin wrapper rather than a fully pooled object, event pooling, where the same object got reused and nulled out after the handler ran, was removed in React 17, so you can safely use the event asynchronously now, in a setTimeout or after an await, without it having been reset to null underneath you.

Before React 18, multiple setState calls inside a React event handler got batched into a single re-render, but the same calls inside a setTimeout, a promise callback, or a native event listener each triggered their own separate re-render. That inconsistency meant code that looked identical behaved differently depending on where it ran.

React 18 introduced automatic batching everywhere, inside promises, timeouts, and native event handlers too, so two setState calls in a timeout now only re-render once instead of twice.

The practical effect for most apps is fewer wasted renders without changing any code, but it can surface bugs in code that assumed a render would happen synchronously between two state updates outside an event handler. If you genuinely need to force a synchronous update in one specific spot, flushSync from react-dom is the escape hatch, though it should be rare.

The setTimeout part is the easy 80 percent, delay firing the actual search until the user has paused typing for, say, 300ms, and clear the previous timeout on every keystroke so only the last one survives. The parts people skip are what happen after that timeout fires: canceling a search request that's still in flight when a newer one starts, so a slow response for a shorter query doesn't land after a faster response for a longer one and overwrite the correct results, and making sure the loading state reflects the debounce delay itself, not just the network request, otherwise the UI looks unresponsive during the pause.

jsx
useEffect(() => {
  if (!query) return;
  const controller = new AbortController();

  const timer = setTimeout(() => {
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setResults)
      .catch(err => { if (err.name !== "AbortError") console.error(err); });
  }, 300);

  return () => {
    clearTimeout(timer);
    controller.abort();
  };
}, [query]);

The cleanup function does double duty here. It clears the pending timer if the user types again before it fires, and aborts the request if one is already in flight, which together handle both the too-eager and too-slow failure modes of a naive debounce.

Testing Library's query priority, role first, then label text, then visible text, with test ID as a last resort, exists because it steers tests toward asserting on what a user actually perceives and interacts with, a button with accessible name Submit, rather than implementation details like a CSS class or a specific div. A test that queries by role fails when the accessible behavior actually breaks, and keeps passing through refactors that change markup but not behavior, which is closer to what you want a test to protect.

The act warning shows up when your test triggers a state update, an async fetch resolving, a timer firing, a promise chain finishing, but the test finishes checking the DOM before React has finished processing that update. Testing Library's async queries already wrap this correctly for you in most real cases. The warning usually means there's an update happening outside the boundaries the test is aware of, often a fetch you forgot to await or a timer you didn't fake and advance. Fixing it is about making the test properly wait for the async work, not suppressing the warning.

When you call setCount with count plus one directly, you're capturing whatever count was in that specific render's closure at the time the function ran. If you call it twice in the same handler, both calls read the same stale count, so you end up incrementing by one total instead of two, since neither call sees the other's result.

The functional form fixes this because React guarantees the value you receive is the actual current state at the moment that specific update gets applied, not whatever was captured in the closure when the handler was defined. So calling the updater twice correctly increments by two, since the second call's value already reflects the first call's result.

This matters most inside effects, timeouts, or event handlers that might run after other updates have already been queued, situations where you can't be certain your closure has the freshest value. As a rule, if your next state depends on the previous state, prefer the function form over reading the state variable directly.

Hard questions

12

Because the effect closes over the count value from the render it was created in, and with an empty dependency array, that's render zero, forever.

jsx
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // always reads count = 0
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>{count}</p>;
}

Every tick calls setCount(0 + 1), so the value keeps getting reset to 1 instead of climbing. It's a textbook stale closure. Two fixes: pass a function to the updater, setCount(c => c + 1), so React hands you the current value instead of the one captured at effect-creation time, or add count to the dependency array and accept that the interval tears down and recreates every render. Interviewers want to hear "stale closure" named out loud, and the functional-updater fix offered without being prompted for it.

The popular answer people give is "Redux is for big apps," which isn't really the mechanism. Plenty of big apps run fine on Context plus a handful of reducers. The real technical reason is selector-based subscription.

Redux, through useSelector, and libraries like Zustand let a component subscribe to one slice of state and only re-render when that specific slice changes. Context can't do that natively, any change to the context value re-renders every consumer of that context, full stop, whether or not the consumer cares about the part that changed. My take here could be wrong, but most teams don't need Redux in 2026. Zustand, or plain Context plus useReducer, covers most of what Redux used to get reached for by default, and it's a smaller mental model to hand a new hire.

First, confirm it's actually the list re-rendering and not just the parent. React DevTools Profiler with "highlight updates" makes this obvious in about ten seconds.

If the whole list is re-rendering: the filtered array is probably being recreated with .filter() on every render without memoizing it (useMemo keyed on the search term and the source data), row components aren't wrapped in memo, or a callback passed into each row is a fresh function reference every render. Beyond that, virtualization (react-window is the common choice) means only the roughly twenty rows visible in the viewport ever mount to the DOM at all, which matters more than micro-memoization once a list gets past a few hundred rows. Interviewers want to see whether you reach for virtualization or just keep piling on more memo calls, which helps less and less past a certain size.

Every time the input value changes, schedule a timeout to copy it into the debounced value after a delay. The cleanup function does the real work: it clears the previous timer before the effect re-runs, so only the last keystroke in a burst survives long enough to actually fire.

jsx
function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

Interviewers probe two things here: do you remember the cleanup function at all (candidates who forget it end up with a debounce that fires once per keystroke instead of once per pause, which defeats the entire point), and can you explain why the effect depends on [value, delay] instead of running once.

Hydration is the process where React takes server-rendered HTML that's already sitting in the DOM and attaches event listeners and internal state to it on the client, instead of throwing that markup away and rendering fresh. For that to work, the client's first render has to produce exactly the same output as what the server sent, node for node. A mismatch happens when it doesn't. Common causes are using the current time or a random value during render, since those differ between server and client, reading window or localStorage during render, since they don't exist on the server, rendering different content based on a media query that only resolves correctly in the browser, or browser extensions injecting attributes into the DOM before React hydrates.

When React detects a mismatch, its behavior depends on severity. For small attribute differences it will patch them and log a warning in development, but for structural differences, different elements, different counts of children, it throws away the server-rendered subtree and re-renders it from scratch on the client, which means users briefly see a flash of the wrong content and you lose the performance benefit SSR was supposed to give you for that part of the tree.

The reliable fix is to make sure anything that can differ between server and client, current time, random IDs, viewport-dependent rendering, is either computed the same way on both sides or deferred to run only after mount, typically by rendering a placeholder on first render and swapping in the real value inside a useEffect, which only runs on the client.

Traditional SSR still ships the component's JavaScript to the browser. The server just renders the initial HTML for a faster first paint, and then the client re-runs that same code to hydrate. Server Components never ship their code to the browser at all. They run only on the server or at build time, have direct access to things like a database or filesystem without an API layer in between, and send a special serialized description of their output to the client, not JavaScript, not even HTML directly, a format the client runtime turns into UI without needing the server component's source code shipped down.

The practical dividing line is interactivity and browser APIs. A component needs the client directive, and becomes a client component that does ship its JS and does hydrate normally, the moment it needs useState, useEffect, event handlers, or anything from the browser, localStorage, window, refs to DOM nodes. Everything else, especially data-fetching components that just read from a database or call an internal service and render markup, is free to stay a server component, and doing so shrinks the client bundle since none of that code or its dependencies gets sent down.

A common mistake is treating the client boundary as contagious upward instead of downward. Once you mark something as a client component, everything it imports also ends up in the client bundle, but a server component can still render a client component as a child and pass it server-fetched data as props, which is the pattern most frameworks like Next.js's App Router lean on.

If you subscribe to an external data source, a browser API like window size, a third-party store, a WebSocket, by calling useState and subscribing inside useEffect, that works most of the time, but it can produce visible inconsistencies under React 18's concurrent rendering, where React can pause a render partway through, work on something else, and resume it later. If the external store changes state in between, different components reading that same store during the same overall update can end up rendering with different values for what should be one consistent state, a phenomenon called tearing.

useSyncExternalStore is built specifically to prevent that. You give it a subscribe function and a getSnapshot function, and React guarantees every component reading from that store during a given render sees the same snapshot, even under concurrent rendering, and it forces a synchronous re-render if the store changes during a render that would otherwise produce a mismatch.

jsx
function useWindowWidth() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener("resize", callback);
      return () => window.removeEventListener("resize", callback);
    },
    () => window.innerWidth
  );
}

Most app code never calls this hook directly. It's the primitive that libraries like Redux, Zustand, and Jotai use internally to connect their stores to React safely, so knowing it exists mainly helps you understand why those libraries are built the way they are, and why a raw useState-based subscription is technically not safe under concurrent features.

A higher-order component is a function that takes a component and returns a new one with extra props injected, a function that returns a component rendering the original with a currentUser prop wired in. A render prop is a component that takes a function as a prop, often called children or render, and calls it with some internal state, so a mouse tracker component's children function receives x and y coordinates to render a cursor with. Both solved the same core problem, sharing stateful logic between components without duplicating it, but both did it by wrapping components in more components.

The practical cost showed up in two places. First, wrapper hell. If a component used auth, theming, and mouse tracking, you'd nest three HOCs or three levels of render-prop functions around it, which made the React DevTools tree hard to read and made it genuinely difficult to trace where a prop actually came from. Second, HOCs in particular had real footguns: forgetting to forward refs through the wrapper, static methods on the wrapped component not carrying over automatically, and prop name collisions when two HOCs both tried to inject a prop with the same name.

Hooks fix this by letting you share logic as a plain function that runs inside the component itself, no wrapping, no extra tree depth, no prop collisions, since a custom hook like useAuth or useMousePosition just returns values directly into the component's own scope. That's the main reason render props and HOCs are now mostly seen in legacy codebases or specific library APIs, Formik's render props, for instance, rather than being a first choice for new code.

The test is whether the value can be produced purely from other state or props you already have, with no extra information. If a component has a list of items and needs a count and a filtered subset, both are fully determined by the items array, so storing them in their own useState is redundant, and worse, it creates a second source of truth that can drift out of sync if you ever update items in one place and forget to update the derived copy in another.

The failure mode that makes this bug show up in code review a lot is a useEffect that watches items and calls a setter to keep the derived value in sync. That works, but it's strictly worse than just computing the derived value directly in the render body. It adds an extra render pass, the effect runs after the render that changed items, so the UI briefly shows stale data before the effect catches up, and it adds a dependency array you now have to maintain correctly.

State should hold information that genuinely can't be recomputed, the actual items array itself, a text input's current value, a boolean the user toggled. Anything downstream of that, which itself has no independent existence, is calculation you can do inline or memoize with useMemo if it's expensive, not state you store and try to keep synchronized by hand.

React decides whether to update an existing component instance or throw it away and create a new one partly based on its key, along with its position and type. If you change a component's key, React treats it as a completely different element, unmounts the old instance entirely, discarding all its internal state and refs, and mounts a brand-new instance from scratch, running its initializers fresh.

This is genuinely useful, not a hack, when a component's internal state should reset whenever some external identity changes, a form editing a specific record, where switching from editing one user to editing another should wipe out any half-typed edits rather than leaving the old form state visually mixed with new data.

jsx
<EditUserForm key={user.id} user={user} />

Without the key, if EditUserForm keeps its own useState for form fields, changing the user prop alone won't reset that internal state automatically, since it's the same component instance being updated, not replaced, so you'd need an effect watching the user id to manually reset every field. Keying on user.id makes React do that reset for you by construction, at the cost of remounting everything underneath, which is worth being aware of if that subtree has expensive setup work, an animation restarting, a video replaying from the start, an API call refiring, since all of that fires again too.

Passing configuration data, like a string or an icon name, forces the receiving component to know about every possible variant up front and branch on it internally with a chain of conditionals. Passing an actual component as a prop inverts that. The parent decides exactly what renders, and the child component just places it wherever its layout needs it, without knowing or caring what it actually is.

This is the same idea children already gives you, just generalized to more than one slot. A Card component might accept a header prop and a footer prop alongside children, letting a consumer fully customize three different regions of the layout while the Card itself only owns structure and styling, not content.

jsx
function Card({ header, children, footer }) {
  return (
    <div className="card">
      <div className="card-header">{header}</div>
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}

The main tradeoff versus a render prop function is that a plain element prop like this gets created once by the parent and passed down as-is, so it can't easily receive values only the child component has access to. If the child needs to hand data back up into what's rendered, an index, an internal measurement, a function-as-prop or render prop is still the better fit, since it lets the child call that function with whatever context it wants to expose.

React.memo does a shallow comparison of props, and useCallback only prevents a function reference from changing between renders if its dependency array is stable, so the most common cause is that one of the other props isn't actually stable even though it looks like it should be. Passing an inline object or array literal as a prop, a style object or an items array written directly in JSX, creates a brand-new reference every render regardless of memo, since object literals aren't reference-equal to the previous render's object even when their contents are identical.

The second common cause is a useCallback with a dependency that itself changes every render. If your callback closes over an object from props that isn't memoized, the callback's identity changes every time that object changes, which cascades right through the memoization and defeats the purpose. Wrapping the callback doesn't help if what it depends on isn't stable too. Memoization has to hold all the way down the chain, one unstable link breaks it.

The fastest way to actually confirm the cause rather than guess is the Profiler tab in React DevTools. It can highlight why a component rendered and show you which specific prop changed reference between two renders, which turns this from a guessing exercise into reading the actual diff.

How to prepare for a React developer interview in 2026

Skip the flashcard approach to hooks. Memorizing that useEffect "runs after render" doesn't help you when an interviewer hands you a broken counter and asks you to explain why it's stuck. Build something small with a few interacting components (a search-and-filter list, a form with client-side validation, a simple cart) and then deliberately break it: remove a dependency, swap a key for an index, wrap something in memo that doesn't need it. Fixing your own bugs teaches the mental model faster than reading about it does.

Across mock interviews we've run through LastRoundAI's frontend track, the pattern that shows up over and over isn't candidates writing wrong code. It's candidates who write correct code and then can't explain, out loud, why it's correct, or worse, can't explain why a piece of their own code re-rendered when they didn't expect it to. That skill, narrating your reasoning about rendering behavior in real time, doesn't show up in a LeetCode grind and it rarely gets practiced until the actual interview. I don't have good data on how this holds up for React Native shops specifically, this page is written from web-React loops, so treat the RN-specific parts of your prep separately.

On Server Components: you don't need deep RSC expertise for most mid-level React roles in 2026, but knowing the three signals that push a component to the client, Hooks, event handlers, and browser APIs, is worth thirty seconds of prep. If a component uses any of those, it needs 'use client' at the top of the file, placed as deep in the tree as reasonable rather than slapped on an entire page (React docs, the 'use client' directive). Senior and staff candidates should expect at least one question on when server-rendering a component saves a network round trip versus when it just adds complexity for no real gain.

Get the reps in before the real thing

Reading answers is not the same as defending them under a follow-up question. LastRoundAI's mock interview mode runs live coding and behavioral rounds with real-time feedback in your browser, and the free plan includes 15 credits a month that reset monthly rather than stockpiling, so there's no reason to save them for "later."

Once you've got the answers down cold, the slower part of the job hunt is usually just getting in front of enough companies. Auto-Apply queues tailored applications to frontend and React-heavy roles for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and you approve every application before anything actually gets sent. Nothing goes out on your behalf without you looking at it first.

Questions about either product go to contact@lastroundai.com, that's the only inbox we check.

Leave a Reply

Your email address will not be published. Required fields are marked *