ReactAdvanced

React Interview: State Management, Performance & Modern React

The senior round — Context and when it hurts, choosing a state library, diagnosing wasted re-renders with the Profiler, virtualization, error boundaries and Suspense, plus Server Components and what changed in React 18 and 19.

4 sections · ~38 min · 5-question quiz (pass ≥ 70%)

1Context, Prop Drilling, and Choosing a State Solution

Context solves prop drilling, not state management. It is a transport mechanism — it does not add caching, batching, or selective subscriptions.

const ThemeContext = createContext(null);

function App() {
  const [theme, setTheme] = useState("light");
  const value = useMemo(() => ({ theme, setTheme }), [theme]);   // see below
  return (
    <ThemeContext.Provider value={value}>
      <Layout />
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
  return ctx;
}

That guard-throwing custom hook is the idiomatic pattern and worth showing.

The performance trap: every consumer re-renders whenever the context value's reference changes, regardless of which part of it they read. value={{ theme, setTheme }} creates a new object every render, so all consumers re-render on any parent render. Fixes:

  • useMemo the value (as above).
  • Split contexts — one for rarely-changing data, one for frequently-changing data. Or split state and dispatch into two contexts, since dispatch is stable.
  • For genuinely high-frequency updates, use a store with selector-based subscriptions instead.

Choosing a solution — the answer interviewers want is a decision tree, not a favourite library:

Situation Answer
Used by one component useState
Complex transitions in one component useReducer
Shared by a subtree, changes rarely (theme, user, locale) Context
Server data (lists, details, mutations) React Query / SWR — caching, revalidation, dedupe
Complex shared client state across routes Zustand / Redux Toolkit / Jotai
Belongs in the URL (filters, tabs, page) Query params — shareable and back-button friendly

The single strongest point you can make here: most "global state" in real apps is server cache, and it should not live in Redux at all. Separating server state from client state, and naming React Query as the tool for the former, is a senior-level answer.

2Diagnosing and Fixing Wasted Renders

Measure first. The React DevTools Profiler records a commit and shows which components rendered and why ("Why did this render?" must be enabled in settings). Guessing at performance is the mistake; the tool is the answer.

Why a component re-renders — the complete list:

  1. Its own state changed.
  2. Its parent re-rendered (default behaviour — props do not need to change).
  3. A context it consumes changed.
  4. Its key changed (that is a remount, not a re-render).

Fix in this order:

1. Move state down. If only a leaf uses a piece of state, put it in that leaf. This is free and beats any memoization.

2. Lift content up / pass children. A parent whose state changes does not re-render children it received as props:

function Wrapper({ children }) {
  const [open, setOpen] = useState(false);
  return <div>{children}</div>;   // children were created by the PARENT;
}                                  // toggling open does not re-render them

3. Then memoizeReact.memo on the child, useCallback/useMemo on the props it receives. All three are needed together; memoizing the child while passing a fresh inline arrow function does nothing.

const Row = React.memo(function Row({ item, onSelect }) { ... });

const handleSelect = useCallback((id) => setSelected(id), []);
<Row item={item} onSelect={handleSelect} />

Beyond re-renders:

  • Long lists → virtualization: render only the visible window (react-window, @tanstack/react-virtual). This is the expected answer for "10,000 rows".
  • Big bundlesReact.lazy + Suspense for route-level code splitting.
  • Expensive derived datauseMemo, but confirm it is actually expensive first.
  • Images → lazy loading, correct sizing, modern formats.
  • Blocking updatesuseTransition marks an update non-urgent so typing stays responsive while an expensive list re-filters.

Reframe the question when asked "how do you optimise React?" Start with "I'd profile to find what's actually slow", then talk about network and bundle size before micro-optimising renders. Most real React slowness is data fetching and payload size, not reconciliation.

3Error Boundaries, Suspense, and Data Fetching Patterns

Error boundaries catch render-time errors in their subtree and show a fallback instead of unmounting the whole app. They must be class components (there is no hook equivalent yet) — a fact interviewers like to check:

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() { return { hasError: true }; }

  componentDidCatch(error, info) { logToService(error, info); }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

They do NOT catch: errors in event handlers, async code (setTimeout, promise rejections), server-side rendering, or errors thrown inside the boundary itself. Handle those with try/catch where they happen. Knowing the exclusions is the real question.

Suspense lets a component declare "I'm not ready" and show a fallback while it waits:

<Suspense fallback={<Skeleton />}>
  <LazyDashboard />
</Suspense>

Used for React.lazy code splitting, and — with a Suspense-enabled data layer or Server Components — for data too.

Data fetching patterns, worst to best:

  1. useEffect + fetch — the baseline. Waterfalls, no cache, manual loading/error/race handling. Fine for an interview if you handle cleanup.
  2. A useFetch custom hook — same mechanics, deduplicated code.
  3. React Query / SWR — caching, background revalidation, request deduplication, retries, pagination, optimistic updates. The right production answer.
  4. Server Components / route loaders — fetch on the server, ship no fetching code to the client at all.

The waterfall problem: a parent fetches, renders a child, the child then fetches. Two sequential round trips. Fix by hoisting both fetches to the parent and running them in parallel (Promise.all), or by fetching at the route level.

Optimistic updates — apply the change locally, fire the request, roll back on failure. Expect to be asked how you would implement a "like" button that feels instant.

4Modern React: Server Components, React 18 & 19

Server Components (RSC) run on the server only. Their code is never shipped to the browser; they can query a database directly and render to a serialised tree.

// Server Component (default in the Next.js App Router)
export default async function Page() {
  const users = await db.user.findMany();     // no useEffect, no API route
  return <UserList users={users} />;
}

Client Components are opted in with "use client" and are what you need for state, effects, refs, event handlers, and browser APIs.

The rules that get asked:

  • A Server Component can render a Client Component; the reverse only works by passing the server-rendered element through as children.
  • Props crossing the server→client boundary must be serialisable — no functions, no class instances, no Dates in some setups.
  • Server Components reduce bundle size and remove client-side data waterfalls; they cannot hold state.
  • RSC is not the same as SSR: SSR renders your client components to HTML on each request; RSC components never hydrate at all.

React 18 additions:

  • Automatic batching everywhere, including promises and timeouts.
  • Concurrent rendering — React can interrupt and resume a render.
  • useTransition / startTransition to mark low-priority updates; useDeferredValue for a lagging copy of a value.
  • Strict Mode double-invokes effects in development to surface missing cleanup.
  • createRoot replaced ReactDOM.render.

React 19 additions:

  • ActionsuseActionState, useFormStatus, and useOptimistic build pending/error/optimistic handling into form submission.
  • The React Compiler auto-memoizes, making most manual useMemo/useCallback unnecessary.
  • use() — read a promise or context conditionally during render.
  • ref is now a regular prop; forwardRef is no longer required for new code.
  • Document metadata (<title>, <meta>) can be rendered directly from components.

How to use this in an interview: you are not expected to have shipped all of it. Being able to say what problem each feature solves — "the compiler exists because manual memoization is error-prone and easy to get wrong" — is what demonstrates real understanding.

Ready to test yourself?

Sign in to take the quiz, track progress, and earn a certificate.

Sign in