React Interview Questions: Hooks, Performance, and Architecture (2025)

React powers the frontend of most major tech companies. Interviewers test your understanding of React internals, hooks, state management, and performance — not just your ability to write JSX. This guide covers everything from junior to staff-level React interview questions.

React Fundamentals

1. How does the Virtual DOM work?

/**
 * Virtual DOM: React maintains an in-memory tree of JavaScript objects
 * mirroring the real DOM. On state change:
 *
 * 1. React creates a new virtual DOM tree (diffing)
 * 2. Compares with previous virtual DOM (reconciliation)
 * 3. Computes minimal set of real DOM updates
 * 4. Applies only the necessary changes (commit phase)
 *
 * Reconciliation rules:
 *   - Different element types → tear down and rebuild subtree
 *   - Same element type → update props in place
 *   - Lists → use `key` prop for stable identity
 *
 * Fiber: React 16+ internal scheduler that makes reconciliation
 * interruptible — high-priority updates (user input) can pause
 * lower-priority renders (network data).
 */

// Example: keys in lists matter for reconciliation
function TodoList({ items }) {
  return (
    <ul>
      {/* Bad: index as key — breaks when list reorders */}
      {items.map((item, i) => <li key={i}>{item.text}</li>)}

      {/* Good: stable ID as key */}
      {items.map(item => <li key={item.id}>{item.text}</li>)}
    </ul>
  );
}

2. useState vs useReducer — when to use each

import { useState, useReducer, useCallback } from "react";

// useState: simple values, independent state
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// useReducer: complex state with multiple sub-values or state machines
type CartState = {
  items: { id: string; qty: number; price: number }[];
  coupon: string | null;
  total: number;
};

type CartAction =
  | { type: "ADD_ITEM";    item: CartState["items"][0] }
  | { type: "REMOVE_ITEM"; id: string }
  | { type: "APPLY_COUPON"; code: string }
  | { type: "CLEAR" };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM": {
      const existing = state.items.find(i => i.id === action.item.id);
      const items = existing
        ? state.items.map(i => i.id === action.item.id
            ? { ...i, qty: i.qty + 1 } : i)
        : [...state.items, action.item];
      return { ...state, items, total: items.reduce((s,i) => s + i.price*i.qty, 0) };
    }
    case "REMOVE_ITEM": {
      const items = state.items.filter(i => i.id !== action.id);
      return { ...state, items, total: items.reduce((s,i) => s + i.price*i.qty, 0) };
    }
    case "APPLY_COUPON":
      return { ...state, coupon: action.code };
    case "CLEAR":
      return { items: [], coupon: null, total: 0 };
    default:
      return state;
  }
}

function Cart() {
  const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null, total: 0 });
  const addItem = useCallback((item) => dispatch({ type: "ADD_ITEM", item }), []);
  return <div>Total: ${cart.total}</div>;
}

3. useEffect — dependency array pitfalls

import { useEffect, useRef, useState } from "react";

function DataFetcher({ userId }: { userId: string }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let cancelled = false;  // Cleanup: prevent stale setState

    async function fetchData() {
      const result = await fetch(`/api/users/${userId}`).then(r => r.json());
      if (!cancelled) setData(result);
    }

    fetchData();
    return () => { cancelled = true; };  // Runs on unmount or userId change
  }, [userId]);  // Re-runs whenever userId changes

  return <div>{JSON.stringify(data)}</div>;
}

// Custom hook: extract reusable async logic
function useFetch<T>(url: string) {
  const [state, setState] = useState<{
    data: T | null; loading: boolean; error: Error | null;
  }>({ data: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;
    setState(s => ({ ...s, loading: true, error: null }));
    fetch(url)
      .then(r => r.json())
      .then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
      .catch(error => { if (!cancelled) setState({ data: null, loading: false, error }); });
    return () => { cancelled = true; };
  }, [url]);

  return state;
}

// Common mistake: function in deps without useCallback
function Parent() {
  const [count, setCount] = useState(0);

  // Bad: new function reference every render → infinite loop
  // useEffect(() => { fetchData(getData); }, [getData]);
  // function getData() { return count; }

  // Good: memoize with useCallback
  const getData = useCallback(() => count, [count]);
  useEffect(() => { /* use getData */ }, [getData]);

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Performance Optimization

import { memo, useMemo, useCallback, lazy, Suspense } from "react";

// React.memo: skip re-render if props unchanged (shallow compare)
const ExpensiveRow = memo(function Row({ name, score, onClick }: {
  name: string; score: number; onClick: (name: string) => void;
}) {
  console.log("Row render:", name);
  return <tr onClick={() => onClick(name)}><td>{name}</td><td>{score}</td></tr>;
});

// useMemo: cache expensive computation
function Leaderboard({ scores }: { scores: { name: string; score: number }[] }) {
  const sorted = useMemo(
    () => [...scores].sort((a, b) => b.score - a.score),
    [scores]  // Only re-sort when scores array changes
  );

  // useCallback: stable function reference for memoized children
  const handleClick = useCallback((name: string) => {
    console.log("Selected:", name);
  }, []);  // No deps = stable forever

  return (
    <table>
      {sorted.map(s => <ExpensiveRow key={s.name} {...s} onClick={handleClick} />)}
    </table>
  );
}

// Code splitting: load components lazily
const HeavyChart = lazy(() => import("./HeavyChart"));

function Dashboard() {
  return (
    <Suspense fallback={<div>Loading chart...</div>}>
      <HeavyChart />
    </Suspense>
  );
}

// useTransition: mark slow state updates as non-urgent
import { useTransition } from "react";

function SearchBar() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);  // Urgent: update input immediately
    startTransition(() => {
      setResults(expensiveSearch(e.target.value));  // Non-urgent: can defer
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultsList results={results} />}
    </>
  );
}

State Management Patterns

import { createContext, useContext, useReducer } from "react";

// Context + useReducer: lightweight global state (no Redux needed for simple apps)
type AuthState = { user: { id: string; name: string } | null; token: string | null };
type AuthAction = { type: "LOGIN"; user: AuthState["user"]; token: string }
                | { type: "LOGOUT" };

const AuthContext = createContext<{
  state: AuthState;
  dispatch: React.Dispatch<AuthAction>;
} | null>(null);

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case "LOGIN":  return { user: action.user, token: action.token };
    case "LOGOUT": return { user: null, token: null };
    default:       return state;
  }
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, { user: null, token: null });
  return (
    <AuthContext.Provider value={{ state, dispatch }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be inside AuthProvider");
  return ctx;
}

// When to use Redux/Zustand vs Context:
// Context: auth, theme, locale — low update frequency, consumed broadly
// Zustand/Redux: shopping cart, filters — high update frequency or complex selectors

Common Interview Patterns

Question Key Answer Points
When does a component re-render? State change, parent re-render, context change, forceUpdate
useState vs useRef useState triggers re-render; useRef persists without re-render
Controlled vs uncontrolled inputs Controlled: value from state; uncontrolled: ref/DOM reads value
Lifting state up Move state to closest common ancestor; pass down as props
Error boundaries Class components with componentDidCatch; catch render errors in subtree
Strict Mode double-invoke Dev-only: ensures effects are cleanup-safe; not a bug
useLayoutEffect vs useEffect useLayoutEffect fires before browser paint; use for DOM measurements

Frequently Asked Questions

What is the difference between useCallback and useMemo in React?

useMemo memoizes a computed value — it caches the result of a calculation and recomputes only when dependencies change. useCallback memoizes a function reference — it returns the same function object across renders unless dependencies change. Both prevent unnecessary re-renders in child components that use React.memo or depend on stable references.

When does a React component re-render?

A component re-renders when: (1) Its own state changes via useState or useReducer. (2) Its parent re-renders and passes new props. (3) A context it subscribes to changes. (4) forceUpdate is called. React.memo, useMemo, and useCallback help skip unnecessary re-renders by maintaining referential equality.

What is the React Event Loop and how does Fiber affect rendering?

React Fiber (introduced in React 16) makes rendering interruptible. Before Fiber, rendering was synchronous and could block the main thread. Fiber breaks rendering into units of work that can be paused, aborted, or resumed. High-priority updates (user input) can interrupt low-priority renders (data loading). useTransition exposes this to application code.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is the difference between useCallback and useMemo in React?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “useMemo memoizes a computed value — it caches the result of a calculation and recomputes only when dependencies change. useCallback memoizes a function reference — it returns the same function object across renders unless dependencies change. Both prevent unnecessary re-renders in child components that use React.memo or depend on stable references.”
}
},
{
“@type”: “Question”,
“name”: “When does a React component re-render?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A component re-renders when: (1) Its own state changes via useState or useReducer. (2) Its parent re-renders and passes new props. (3) A context it subscribes to changes. (4) forceUpdate is called. React.memo, useMemo, and useCallback help skip unnecessary re-renders by maintaining referential equality.”
}
},
{
“@type”: “Question”,
“name”: “What is the React Event Loop and how does Fiber affect rendering?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “React Fiber (introduced in React 16) makes rendering interruptible. Before Fiber, rendering was synchronous and could block the main thread. Fiber breaks rendering into units of work that can be paused, aborted, or resumed. High-priority updates (user input) can interrupt low-priority renders (data loading). useTransition exposes this to application code.”
}
}
]
}

Scroll to Top