React Interview Questions: Mid-Level to Senior

React Interview Q&A

A tech-interview-style reference for mid-level to senior React developers. Each question is followed by a concise answer — the kind of response that signals deep understanding without over-explaining.


Table of Contents

  1. What triggers a re-render in React?
  2. What is the difference between useMemo and useCallback?
  3. When would you use useReducer instead of useState?
  4. What is the stale closure problem in useEffect?
  5. How does React's reconciliation algorithm work?
  6. What is the purpose of the key prop and when does it matter?
  7. What is useTransition and when should you reach for it?
  8. What is the difference between a controlled and uncontrolled component?
  9. How does Context work under the hood and what are its performance implications?
  10. How would you prevent unnecessary re-renders in a large component tree?
  11. What are React Server Components and how do they differ from Client Components?
  12. What is the "use client" directive and what does it actually mark?
  13. What is a custom hook and what rules must it follow?
  14. How do you test a component that fetches data?
  15. What is prop drilling and what are the alternatives?
  16. Explain the useRef hook — what can it do that useState cannot?
  17. What is a pure component and why does React require purity?
  18. How does batching work in React 18+?
  19. What is an Error Boundary and what can it not catch?
  20. How does Suspense work and how does it interact with data fetching?
  21. What is the use() hook introduced in React 19?
  22. When would you lift state up vs. reach for context vs. an external store?
  23. What is the difference between useEffect and useLayoutEffect?
  24. How does the React Compiler change memoization strategy?
  25. How would you structure a large React application?
  26. What does JSX compile to and why does that matter?
  27. What are React Fragments and when should you use them?
  28. What is React StrictMode and what does it actually do at runtime?
  29. What is the children prop and how does prop spreading work?
  30. What is the difference between setState(value) and setState(prev => ...)?
  31. Why can't you mutate state directly and how do you correctly update nested objects?
  32. What are the correct ways to update an array in state?
  33. How do you handle a form with many fields without one useState per field?
  34. When is the useEffect cleanup function called?
  35. What happens if you omit a dependency from the useEffect dependency array?
  36. What is useId and why shouldn't you use Math.random() instead?
  37. What is the difference between <Link> and <a> in React Router?
  38. How do you access route parameters and protect routes in React Router?
  39. What is dangerouslySetInnerHTML and when is it acceptable to use it?
  40. What is staleTime vs gcTime in React Query and how does caching work?
  41. How do React Query mutations work and how do you sync them with the cache?
  42. What are Server Actions and how do they interact with forms in React 19?

What triggers a re-render in React?

A component re-renders when:

  • Its own state changes (via setState / useState setter).
  • A prop it receives changes (parent re-rendered and passed a new value).
  • A context value it consumes changes.
  • Its parent re-renders and it is not wrapped in React.memo.

React bails out of rendering subtrees when it can prove the output would be identical (same reference for memoized components).


What is the difference between useMemo and useCallback?

Both memoize between renders. The difference is what they memoize:

  • useMemo caches the return value of a function.
  • useCallback caches the function reference itself.
// useMemo — memoize a computed value
const sorted = useMemo(() => items.sort(compareFn), [items]);

// useCallback — memoize a function so its reference is stable
const handleClick = useCallback(() => doSomething(id), [id]);

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).


When would you use useReducer instead of useState?

When state transitions are complex or interconnected. Good signals:

  • Multiple sub-values that change together.
  • Next state depends on the previous state in non-trivial ways.
  • You want to co-locate transition logic outside the component (easier to test).
function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + 1 };
    case 'reset':     return initialState;
    default: throw new Error('unknown action');
  }
}
const [state, dispatch] = useReducer(reducer, initialState);

For a simple toggle, useState is fine.


What is the stale closure problem in useEffect?

When an effect captures a variable from the render scope, that variable is "frozen" at the time the effect ran. If state updates happen later, the effect still holds the old value.

useEffect(() => {
  const id = setInterval(() => {
    console.log(count); // always logs the initial value of count
  }, 1000);
  return () => clearInterval(id);
}, []); // empty deps — count is stale inside

Fix: either add count to the dependency array, or use a useRef to hold a mutable ref to the latest value.


How does React's reconciliation algorithm work?

React builds a virtual DOM tree and diffs it against the previous one (the "fiber" tree in React 16+). Key rules:

  1. Elements of different types — React destroys the old tree and builds a new one.
  2. Same type, same position — React updates the existing DOM node with changed props.
  3. Lists — React uses the key prop to match children across renders and avoid unnecessary unmounts.

The fiber architecture makes this work in a concurrent model: reconciliation can be paused, interrupted, and resumed.


What is the purpose of the key prop and when does it matter?

key is React's hint for identity in a list. When keys stay stable, React reuses existing DOM nodes. When a key changes, React unmounts the old component and mounts a new one — this resets all state.

Two practical uses:

  • Stable, unique keys on list items to prevent state leaking between reordered items.
  • Intentionally changing a key to force a full reset of component state without an useEffect.
// Force reset when userId changes — no useEffect needed
<UserProfile key={userId} userId={userId} />

What is useTransition and when should you reach for it?

useTransition lets you mark a state update as non-urgent so React can keep the UI responsive while it processes the expensive update in the background.

const [isPending, startTransition] = useTransition();

startTransition(() => {
  setSearchResults(computeExpensiveFilter(query));
});

The current UI stays interactive during isPending. Useful for filtering large lists, switching tabs with heavy renders, or any update that is expensive but not time-critical.


What is the difference between a controlled and uncontrolled component?

ControlledUncontrolled
Source of truthReact stateDOM
How to readvalue propref.current.value
ValidationOn every keystrokeOn submit
// Controlled
<input value={name} onChange={e => setName(e.target.value)} />

// Uncontrolled
const ref = useRef();
<input ref={ref} defaultValue="Jane" />

Controlled is preferred for forms that need instant validation or derived UI. Uncontrolled works for simple forms or integrating with non-React libraries.


How does Context work under the hood and what are its performance implications?

Every component that calls useContext(MyContext) subscribes to that context. When the provider's value prop changes (by reference), every subscriber re-renders, regardless of whether it uses the changed part of the value.

// This causes every consumer to re-render whenever parent re-renders,
// because a new object is created each render:
<MyContext.Provider value={{ user, setUser }}>

Fix: memoize the value object with useMemo, or split one large context into multiple smaller ones so consumers only subscribe to what they need.


How would you prevent unnecessary re-renders in a large component tree?

Layered strategy:

  1. React.memo — wrap pure components that receive stable props.
  2. useCallback / useMemo — stabilize function and object references passed as props.
  3. State colocation — keep state as close to where it's used as possible; don't lift it higher than necessary.
  4. Context splitting — separate frequently-changing values from stable ones.
  5. Virtualization — for long lists, use react-window or react-virtual to only render visible rows.
  6. React Compiler (React 19) — let the compiler automatically memoize where safe.

What are React Server Components and how do they differ from Client Components?

Server ComponentClient Component
Runs onServer onlyBrowser (and SSR on server)
Can use hooksNoYes
Can access server resourcesYes (DB, filesystem, env)No
Sent to browserAs serialized UI, not JSAs JS bundle
Default in Next.js App RouterYesOpt-in via "use client"

Server Components reduce bundle size because their code never ships to the browser. They can await data directly without useEffect.


What is the "use client" directive and what does it actually mark?

"use client" marks a module boundary, not a runtime toggle. It tells the bundler: "everything imported from here downward is client code." The component itself and all its transitive imports become part of the client bundle.

A Server Component can render a Client Component, but a Client Component cannot import and render a Server Component (it can receive one as a children prop however).


What is a custom hook and what rules must it follow?

A custom hook is a function whose name starts with use and that can call other hooks internally. It lets you extract and share stateful logic without changing the component hierarchy.

Rules:

  • Name must start with use.
  • Must only be called at the top level — no conditionals, loops, or nested functions.
  • Must only be called from React function components or other custom hooks.
function useFetch(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch(url).then(r => r.json()).then(setData);
  }, [url]);
  return data;
}

How do you test a component that fetches data?

The standard approach with Vitest + Testing Library:

  1. Mock the network — use vi.mock or msw (Mock Service Worker) to intercept fetch/axios calls.
  2. Wrap in act/waitFor — wait for async state updates to settle before asserting.
vi.mock('../api', () => ({ fetchUser: vi.fn(() => Promise.resolve({ name: 'Ada' })) }));

test('renders user name', async () => {
  render(<UserCard id="1" />);
  expect(await screen.findByText('Ada')).toBeInTheDocument();
});

Prefer msw for integration-style tests — it mocks at the network layer and is closer to real behavior.


What is prop drilling and what are the alternatives?

Prop drilling is passing props through multiple intermediate components that don't use them, just to deliver data to a deeply nested consumer.

Alternatives (in order of increasing complexity):

  • Context — good for global-ish values (theme, auth, locale) that change infrequently.
  • Component composition — pass components as children or render props to skip intermediaries.
  • External state library — Zustand, Jotai, Redux for complex cross-cutting state.
  • URL / router state — for state that should be shareable or bookmarkable.

Explain the useRef hook — what can it do that useState cannot?

useRef returns a mutable container ({ current: value }) that persists across renders without triggering a re-render when mutated.

Two main uses:

// 1. Access a DOM node directly
const inputRef = useRef(null);
<input ref={inputRef} />;
inputRef.current.focus();

// 2. Store a mutable value that shouldn't cause re-renders
const prevCount = useRef(count);
useEffect(() => { prevCount.current = count; });

useState triggers a re-render on update. useRef does not — it's an escape hatch for imperative code.


What is a pure component and why does React require purity?

A pure component produces the same output for the same props and state, and has no side effects during rendering. React requires this because:

  • Rendering may happen multiple times (StrictMode, Concurrent features).
  • React must be able to safely abort and restart renders.
  • Memoization only works correctly when the same inputs always yield the same output.

Side effects (DOM manipulation, network calls, subscriptions) belong in useEffect or event handlers, never in the render body.


How does batching work in React 18+?

Before React 18, batching only happened inside React event handlers. Updates inside setTimeout, Promises, or native event listeners triggered individual re-renders.

React 18 introduced automatic batching — all state updates anywhere (async callbacks, microtasks, native events) are batched into a single re-render by default.

setTimeout(() => {
  setA(1); // React 18: batched — only one re-render
  setB(2);
}, 100);

Use flushSync from react-dom to opt out of batching when you need an immediate synchronous re-render.


What is an Error Boundary and what can it not catch?

An Error Boundary is a class component that implements static getDerivedStateFromError or componentDidCatch. It catches errors thrown during rendering of its subtree and renders a fallback UI.

Cannot catch:

  • Errors inside event handlers (use try/catch there directly).
  • Async errors (inside setTimeout, Promises).
  • Errors in the Error Boundary component itself.
  • Server-side rendering errors (different mechanism).
class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }
  render() {
    return this.state.hasError ? <Fallback /> : this.props.children;
  }
}

How does Suspense work and how does it interact with data fetching?

<Suspense> catches components that "suspend" — i.e., throw a Promise during rendering — and shows a fallback until the Promise resolves.

Libraries like React Query and Next.js data fetching integrate with this. In React 19, the use() hook is the built-in way to suspend on a Promise:

function UserCard({ userPromise }) {
  const user = use(userPromise); // suspends until resolved
  return <p>{user.name}</p>;
}

<Suspense fallback={<Spinner />}>
  <UserCard userPromise={fetchUser(id)} />
</Suspense>

Suspense can be nested — each boundary catches the nearest suspended subtree.


What is the use() hook introduced in React 19?

use() is a hook that can unwrap a Promise or read a Context value, and it can be called conditionally (unlike all other hooks).

// Unwrap a promise — suspends until resolved
const data = use(dataPromise);

// Read context (equivalent to useContext)
const theme = use(ThemeContext);

The key difference from await is that use() integrates with the Suspense boundary — the component suspends rather than blocking. It can be called inside loops or conditionals, which regular hooks cannot.


When would you lift state up vs. reach for context vs. an external store?

SituationSolution
Two sibling components share a piece of stateLift state up to their common parent
State is needed by many deeply nested consumers but changes infrequentlyContext
State changes frequently and many components subscribeExternal store (Zustand, Jotai)
State needs to persist across navigation or be bookmarkableURL / router params
Complex server state (caching, background sync)React Query / SWR

Reaching for global state too early is a common mistake. Start with local state and promote only when the need is clear.


What is the difference between useEffect and useLayoutEffect?

Both run after render, but at different points in the browser pipeline:

  • useEffect — runs asynchronously after the browser has painted. Does not block visual updates.
  • useLayoutEffect — runs synchronously after DOM mutations, before the browser paints. Blocks painting until it completes.

Use useLayoutEffect when you need to read layout (e.g., getBoundingClientRect) and immediately update the DOM to prevent a visible flicker. For everything else, prefer useEffect because it does not block painting.


How does the React Compiler change memoization strategy?

The React Compiler (available in React 19) statically analyzes component code and automatically inserts useMemo and useCallback calls where it can prove the output is the same given the same inputs.

This means you no longer have to manually audit components for missing memoization. The practical impact:

  • You can write code without manual memo/useMemo/useCallback in most cases.
  • Performance optimization becomes a compiler concern, not a developer concern.
  • React.memo wrappers become largely unnecessary for components whose props the compiler can track.

How would you structure a large React application?

There is no single answer, but a structure that scales well:

src/
  features/          # domain-driven modules (auth, dashboard, billing)
    auth/
      components/
      hooks/
      api.ts
      store.ts
  components/        # truly shared, generic UI components
  hooks/             # shared custom hooks
  lib/               # utilities, API client setup
  pages/ (or app/)   # routing layer only — thin, delegates to features

Key principles:

  • Co-locate — keep tests, types, hooks, and components near each other by feature.
  • Dependency directionfeatures depend on components/lib, never the reverse.
  • Thin route files — pages just compose feature components, no business logic.
  • Explicit boundaries — avoid circular imports; consider barrel files (index.ts) per feature.

What does JSX compile to and why does that matter?

JSX is syntactic sugar. The JSX transform (Babel/TypeScript) compiles it to React.createElement calls (or the newer _jsx from react/jsx-runtime):

// JSX
const el = <Button color="blue">Click me</Button>;

// Compiled (classic transform)
const el = React.createElement(Button, { color: 'blue' }, 'Click me');

Why it matters in interviews:

  • Explains why you used to need import React from 'react' at the top of every file (the compiled output referenced React).
  • The newer automatic JSX transform (React 17+) no longer requires that import.
  • Understanding this makes it clear that JSX is just JavaScript objects — React reads that object tree to decide what to render.

What are React Fragments and when should you use them?

A Fragment lets a component return multiple elements without adding an extra DOM node.

// Short syntax
return (
  <>
    <dt>Term</dt>
    <dd>Definition</dd>
  </>
);

// Long syntax — needed when you must pass a key
return items.map(item => (
  <React.Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.def}</dd>
  </React.Fragment>
));

Use the short syntax <> by default. Use <React.Fragment key={...}> only when you need a key on the fragment itself (e.g., mapping pairs of elements).


What is React StrictMode and what does it actually do at runtime?

<StrictMode> is a development-only wrapper. It does not affect production builds. What it does:

  • Double-invokes render functions, state initializers, and reducer functions to surface side effects inside them.
  • Double-fires useEffect setup + cleanup to ensure effects are idempotent and properly cleaned up.
  • Warns about deprecated APIs and unexpected side effects.

The double-firing is the most surprising part. If your effect fires twice and causes a bug (e.g., two event listeners, two subscriptions), the cleanup function is not correctly undoing the setup.


What is the children prop and how does prop spreading work?

children is a built-in prop that holds whatever is nested between a component's opening and closing tags:

function Card({ children, className }) {
  return <div className={`card ${className}`}>{children}</div>;
}
// Used as:
<Card className="featured"><p>Content</p></Card>

Prop spreading passes all properties of an object as props:

const inputProps = { type: 'email', placeholder: 'you@example.com', required: true };
<input {...inputProps} />

Be deliberate about spreading — it can accidentally pass unknown attributes to DOM elements (React will warn) or leak internal props to children. A common pattern is to destructure known props and spread the rest:

function Input({ label, ...rest }) {
  return <><label>{label}</label><input {...rest} /></>;
}

What is the difference between setState(value) and setState(prev => ...)?

When the new state depends on the previous state, always use the updater function form:

// Unsafe — may read a stale snapshot if multiple updates are batched
setCount(count + 1);
setCount(count + 1); // still +1, not +2

// Safe — React guarantees `prev` is the most recent state
setCount(prev => prev + 1);
setCount(prev => prev + 1); // correctly +2

This matters inside event handlers that call setState multiple times, inside useEffect, or any async callback where the closure may have captured a stale count.


Why can't you mutate state directly and how do you correctly update nested objects?

React detects changes by reference equality. If you mutate an object in place, the reference stays the same — React sees no change and skips re-rendering.

// Wrong — mutates in place, no re-render
state.user.name = 'Ada';
setState(state);

// Correct — new reference at every level that changed
setState(prev => ({
  ...prev,
  user: { ...prev.user, name: 'Ada' },
}));

For deeply nested structures, consider a library like Immer (produce) which lets you write mutating-style code while producing a new immutable object under the hood.


What are the correct ways to update an array in state?

Never use mutating methods (push, splice, sort in place). Use methods that return a new array:

OperationCorrect approach
Add[...prev, newItem]
Removeprev.filter(x => x.id !== id)
Update itemprev.map(x => x.id === id ? { ...x, ...changes } : x)
Sort[...prev].sort(compareFn)
// Remove
setItems(prev => prev.filter(item => item.id !== targetId));

// Update one item
setItems(prev => prev.map(item =>
  item.id === targetId ? { ...item, done: true } : item
));

How do you handle a form with many fields without one useState per field?

Use a single state object and update it by field name:

const [form, setForm] = useState({ name: '', email: '', role: '' });

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

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

Alternatively, use useReducer when field updates have interdependencies, or reach for a form library (React Hook Form, Formik) for validation-heavy forms.


When is the useEffect cleanup function called?

The cleanup runs in two situations:

  1. Before the effect re-runs — when dependencies change, React runs the previous cleanup before running the new setup.
  2. When the component unmounts — React runs the cleanup one final time.
useEffect(() => {
  const sub = subscribe(channel); // setup
  return () => sub.unsubscribe(); // cleanup
}, [channel]);

In StrictMode (development), React also runs cleanup + setup an extra time immediately after mount to help you catch missing cleanups. Always clean up: timers, subscriptions, event listeners, AbortControllers.


What happens if you omit a dependency from the useEffect dependency array?

The effect captures a stale closure — it reads the value from the render when it last ran, not the current render. This leads to bugs that are hard to trace:

useEffect(() => {
  fetchData(userId); // userId may be stale
}, []); // should be [userId]

The eslint-plugin-react-hooks exhaustive-deps rule catches this. Always include every reactive value the effect reads. If including a dep causes an infinite loop, the real fix is usually to stabilize that value with useCallback or useRef, not to suppress the lint rule.


What is useId and why shouldn't you use Math.random() instead?

useId generates a stable, unique string per component instance that is consistent between the server render and the client hydration.

function Field({ label }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </>
  );
}

Math.random() generates a different value on the server and on the client — this causes a hydration mismatch in SSR/RSC apps. It also generates a new value on every render. useId avoids both problems.


<Link> performs client-side navigation — it intercepts the click, updates the history stack, and re-renders only the changed parts of the component tree without a full page reload. The scroll position, JS heap, and in-memory state are preserved.

<a href="..."> causes a full browser navigation — the page unloads, a new HTTP request is made, and React re-mounts from scratch.

Use <Link> for internal routes. Use <a> for external URLs or file downloads.


How do you access route parameters and protect routes in React Router?

Route params via useParams:

// Route definition
<Route path="/users/:userId" element={<UserPage />} />

// Inside UserPage
const { userId } = useParams();

Protected routes — wrap with a guard component that redirects unauthenticated users:

function RequireAuth({ children }) {
  const auth = useAuth();
  if (!auth.user) return <Navigate to="/login" replace />;
  return children;
}

// In routes
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />

For programmatic navigation (e.g., after a form submit), use useNavigate:

const navigate = useNavigate();
navigate('/dashboard', { replace: true });

What is dangerouslySetInnerHTML and when is it acceptable to use it?

dangerouslySetInnerHTML={{ __html: content }} injects raw HTML into the DOM, bypassing React's escaping. The double-underscore key is intentional — it forces you to be explicit about the risk.

Risk: if content contains user-supplied input, it opens an XSS vector.

Acceptable uses:

  • Rendering sanitized HTML from a trusted CMS (always sanitize server-side, e.g., with DOMPurify).
  • Embedding third-party widgets that generate their own HTML.
  • Injecting pre-rendered syntax-highlighted code from a server.
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(cmsHtml) }} />

Never pass unsanitized user input directly.


What is staleTime vs gcTime in React Query and how does caching work?

OptionMeaning
staleTimeHow long fetched data is considered fresh. During this window, React Query will NOT refetch in the background. Default: 0 (always stale).
gcTimeHow long unused cached data is kept in memory before being garbage-collected. Default: 5 minutes.
useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 1000 * 60 * 5,  // fresh for 5 minutes
  gcTime: 1000 * 60 * 10,    // kept in cache 10 minutes after last use
});

When a query is stale and a component mounts or the window regains focus, React Query refetches in the background and updates the UI once the new data arrives — without showing a loading spinner if stale data exists.


How do React Query mutations work and how do you sync them with the cache?

useMutation handles write operations (POST/PUT/DELETE). After a successful mutation, you sync the cache via invalidateQueries (refetch) or setQueryData (optimistic update):

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: (data) => api.post('/posts', data),
  onSuccess: () => {
    // Invalidate — triggers a background refetch of the list
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

mutation.mutate({ title: 'New post' });

For optimistic updates, use onMutate to update the cache immediately, then roll back in onError if the request fails.


What are Server Actions and how do they interact with forms in React 19?

Server Actions are async functions marked with "use server" that run exclusively on the server. They can be called directly from Client Components or wired to a form's action prop.

// actions.ts (server)
'use server';
export async function createPost(formData: FormData) {
  const title = formData.get('title');
  await db.posts.create({ data: { title } });
  revalidatePath('/posts');
}
// Client component
import { createPost } from './actions';

<form action={createPost}>
  <input name="title" />
  <button type="submit">Save</button>
</form>

When the form is submitted, Next.js serializes the FormData and calls the server function over the network — no API route needed. They compose with useActionState (React 19) to give you pending state and error handling in the client.