Cheatsheet

Interview Handbook

React Deep Dive — Interview Handbook

Master React internals, hooks, performance patterns, and modern architecture — everything asked in senior React interviews.

10Core Topics
10Quizzes
43Flashcards
31Code Examples
01 — FOUNDATION

JSX, Elements & the Virtual DOM

JSX is a syntax extension for JavaScript that looks like HTML but is transpiled into React.createElement calls. The Virtual DOM is a lightweight JavaScript representation of the real DOM that React uses to efficiently update the UI. Understanding how JSX transforms, how elements are created, and how the Virtual DOM reconciles changes is essential for writing performant React applications.

JSX Transpilation

JSX is not valid JavaScript; it must be transpiled (typically by Babel) into React.createElement calls. Each JSX element becomes a call to React.createElement(type, props, ...children). This transformation happens at build time, not at runtime.

JSX to createElement
// JSX
const element = <h1 className="greeting">Hello, world!</h1>;

// Transpiled to
const element = React.createElement(
  'h1',
  { className: 'greeting' },
  'Hello, world!'
);

React.createElement and Elements

React.createElement returns a plain object called a React element. This object describes what should appear on the screen and includes the type, props, and children. React elements are immutable and cheap to create.

React Element Object
// React element object structure
const element = {
  type: 'h1',
  props: {
    className: 'greeting',
    children: 'Hello, world!'
  }
};

Virtual DOM Concept

The Virtual DOM is an in-memory representation of the real DOM. When state changes, React creates a new Virtual DOM tree, diffs it against the previous one (reconciliation), and calculates the minimal set of DOM operations needed. This batch update process improves performance by avoiding direct, expensive DOM manipulations.

Key Insight

The Virtual DOM is not a copy of the real DOM—it's a lightweight abstraction. Reconciliation uses heuristics (like key props) to minimize the number of nodes that need to be recreated. Without keys, React may unnecessarily unmount and remount components, causing performance issues and lost state.

Keys and Reconciliation Hints

Keys help React identify which items in a list have changed, been added, or removed. They should be stable, unique, and predictable (e.g., a database ID). Using array indices as keys can lead to bugs when items are reordered or filtered.

Keys in Lists
// Good: stable unique key
const todoItems = todos.map(todo =>
  <li key={todo.id}>{todo.text}</li>
);

// Avoid: index as key (can cause issues)
const todoItems = todos.map((todo, index) =>
  <li key={index}>{todo.text}</li>
);

Fragments and Portals

Fragments (React.Fragment or <></>) let you group multiple elements without adding extra nodes to the DOM. Portals (ReactDOM.createPortal) render children into a different DOM subtree, useful for modals, tooltips, and overlays.

Fragments and Portals
// Fragment: no extra wrapper div
function List() {
  return (
    <>
      <li>Item 1</li>
      <li>Item 2</li>
    </>
  );
}

// Portal: render into a different DOM node
import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(
    <div className="modal">{children}</div>,
    document.getElementById('modal-root')
  );
}
🧩Quick Check

What is the primary purpose of the `key` prop in React lists?

Flashcards5
02 — CORE

Reconciliation & Fiber Architecture

🌳 React Reconciliation Decision Tree

How React decides what to update, mount, or unmount. New Virtual DOM Tree is compared to previous tree at same position.

ConditionActionEffect
Element type changed (e.g., <div> → <span>)Unmount + RemountState LOST · child tree destroyed · effects cleaned up
Same type, props unchangedSkip updateReuse existing instance, no re-render needed
Same type, props changedUpdate propsSame instance, state preserved, effects may re-run
⚠ Killer Trap: Child Component Inside Parent

Defining a child component INSIDE a parent creates new function ref every render → React sees different 'type' → unmount+remount each time → state resets!

Bad Practice
function Parent() {
  // ❌ BAD: Child gets fresh identity each render
  const Child = () => <div>...</div>;
  return <Child />;
}
💡 The Key Prop

Helps React track elements in lists. Use stable IDs (not array index!) to avoid unnecessary remounts.

🧩Quick Check

What happens when React detects a type change from <div> to <span> at the same position?

Flashcards4
03 — CORE HOOKS

Core Hooks: useState & useReducer

React's core hooks, useState and useReducer, are the primary tools for managing component state. useState is ideal for simple, independent state values, while useReducer shines when state logic involves multiple sub-values or complex transitions. Understanding their internals—like state batching and functional updates—is crucial for writing efficient, bug-free React code. This section covers how they work under the hood, when to choose one over the other, and patterns like Immer for immutable updates.

useState Internals & State Batching

Each useState call creates a state variable and a setter function. React internally stores state in a linked list of hooks per component. When you call the setter, React schedules a re-render. In React 18+, state updates are automatically batched—even inside promises, timeouts, or native event handlers—meaning multiple setter calls in the same synchronous block trigger only one re-render. This improves performance and prevents intermediate renders.

State batching example
import { useState } from 'react';

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

  const handleClick = () => {
    // React 18+ batches these into one re-render
    setCount(count + 1);
    setCount(count + 1);
    // count will be 1, not 2, because both use the same stale value
  };

  return <button onClick={handleClick}>{count}</button>;
}

Functional Updates

To safely update state based on the previous value, use the functional form of the setter: setCount(prev => prev + 1). This ensures each update uses the latest state, even when multiple updates are batched or queued. It's essential for counters, toggles, and any logic where the new state depends on the old state.

Functional vs direct updates
// Correct: functional update
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// count will be 2

// Wrong: direct value (stale closure)
setCount(count + 1);
setCount(count + 1);
// count will be 1
Key Insight: Functional Updates Prevent Stale Closures

Always use functional updates when the new state depends on the previous state. This avoids bugs from stale closures, especially in event handlers or effects that capture a snapshot of state.

useReducer vs useState Decision Tree

Choose useReducer when: state logic is complex (multiple sub-values), the next state depends on the previous one in intricate ways, or you want to centralize state transitions (like a reducer in Redux). Stick with useState for simple, independent values. A good rule of thumb: if you find yourself calling multiple useState setters in sequence or writing complex setState logic, consider useReducer.

CriteriauseStateuseReducer
State complexitySimple, independent valuesComplex objects or multiple sub-values
Update logicDirect or functional updatesDispatching actions with a reducer
Number of state variablesFew (1-3)Many or nested
TestabilityHarder to testReducer is a pure function, easy to test
PerformanceGood for small stateBetter for large state with many updates

Dispatching Actions & Immer Pattern

With useReducer, you dispatch action objects to a reducer function that returns the new state. For complex state shapes, the Immer library simplifies immutable updates by allowing you to write mutable-looking code. Use produce from Immer inside your reducer to create the next state without manual spreading.

useReducer with Immer pattern
import { useReducer } from 'react';
import { produce } from 'immer';

const initialState = { todos: [], count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return produce(state, draft => {
        draft.todos.push({ id: Date.now(), text: action.payload });
        draft.count++;
      });
    case 'REMOVE_TODO':
      return produce(state, draft => {
        draft.todos = draft.todos.filter(t => t.id !== action.payload);
        draft.count--;
      });
    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'ADD_TODO', payload: 'New Task' })}>
        Add Todo
      </button>
    </div>
  );
}
🧩Quick Check

What happens when you call setCount(count + 1) twice in the same synchronous event handler without functional updates?

Flashcards4
04 — CORE HOOKS

Side Effects: useEffect & useLayoutEffect

Side effects are operations that reach outside the functional component's scope, such as data fetching, subscriptions, timers, or direct DOM manipulation. React's useEffect and useLayoutEffect hooks provide a declarative way to handle these effects, ensuring they synchronize with the component's lifecycle. Mastering these hooks is crucial for building robust, performant React applications.

Dependency Array Rules

The dependency array controls when an effect runs. If omitted, the effect runs after every render. If empty ([]), it runs only once after the initial mount. If populated with variables, the effect re-runs only when those values change. All reactive values (props, state, and derived values) used inside the effect must be listed in the dependency array to avoid stale closures and ensure correctness.

Dependency Array Example
import { useEffect, useState } from 'react';

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

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]); // Re-runs when 'count' changes

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

Cleanup Functions

A cleanup function is returned from the effect callback and runs before the component unmounts or before re-running the effect. It is essential for canceling subscriptions, clearing timers, or aborting fetch requests to prevent memory leaks and unwanted side effects.

Cleanup Function Example
import { useEffect } from 'react';

function Timer() {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('Tick');
    }, 1000);

    return () => clearInterval(interval); // Cleanup on unmount
  }, []);

  return <div>Timer running</div>;
}

useEffect Timing vs useLayoutEffect

useEffect runs asynchronously after the browser has painted the screen, making it ideal for non-blocking operations like data fetching or logging. useLayoutEffect runs synchronously after DOM mutations but before the browser paints, allowing you to read layout and synchronously re-render without visual flicker. Use useLayoutEffect sparingly for DOM measurements or imperative animations.

useLayoutEffect Example
import { useEffect, useLayoutEffect, useRef } from 'react';

function Measure() {
  const ref = useRef(null);

  useLayoutEffect(() => {
    console.log(ref.current.getBoundingClientRect()); // Runs before paint
  }, []);

  return <div ref={ref}>Measured</div>;
}

Fetching in Effects & AbortController

Data fetching inside useEffect is common but requires careful handling of race conditions and cleanup. Using AbortController allows you to cancel an in-flight request when the component unmounts or dependencies change, preventing state updates on unmounted components.

Fetching with AbortController
import { useEffect, useState } from 'react';

function User({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    fetch(`/api/users/${userId}`, { signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort(); // Cancel request on cleanup
  }, [userId]);

  return <div>{user ? user.name : 'Loading...'}</div>;
}
Interview Tip

Be prepared to explain the difference between useEffect and useLayoutEffect with a concrete example, such as measuring DOM elements. Also, demonstrate how to handle race conditions in async effects using a cleanup flag or AbortController.

🧩Quick Check

What happens if you omit the dependency array in useEffect?

Flashcards4
05 — CORE HOOKS

Performance Hooks: useMemo & useCallback

Performance hooks useMemo and useCallback are powerful tools for optimizing React applications, but they come with their own costs. Understanding when and how to use them—and more importantly, when not to—is crucial for writing efficient, maintainable code. This section covers the trade-offs of memoization, referential equality, and common pitfalls.

Memoization Cost vs Benefit

Memoization stores the result of an expensive computation and returns the cached result when the same inputs occur again. However, it introduces overhead: memory for caching and the comparison of dependencies. The benefit only outweighs the cost when the computation is genuinely expensive (e.g., complex data transformations, filtering large arrays) and the dependencies change infrequently. For trivial calculations, the comparison cost can exceed the computation cost.

useMemo Example
// Expensive computation: worth memoizing
const sortedList = useMemo(() => {
  return largeArray.sort((a, b) => b.value - a.value);
}, [largeArray]);

// Trivial computation: NOT worth memoizing
const sum = useMemo(() => a + b, [a, b]); // Overhead > benefit

Referential Equality and When NOT to Memoize

React uses referential equality (===) to compare props and dependencies. Every time a component renders, new object/function references are created, potentially causing unnecessary re-renders in child components wrapped with React.memo. However, memoization is not free. Avoid it when: the computation is cheap, dependencies change on every render (making the cache useless), or the component is simple and re-renders are not a bottleneck. Premature optimization can make code harder to read without tangible benefits.

useCallback for Stable References

useCallback returns a memoized function reference that only changes when its dependencies change. This is essential when passing callbacks to child components wrapped with React.memo or when using them in dependency arrays of other hooks. Without it, a new function is created on every render, breaking referential equality and causing unnecessary re-renders or effect re-runs.

useCallback Example
const handleClick = useCallback(() => {
  setCount(prev => prev + 1);
}, []); // Stable reference, never changes

// Without useCallback, this creates a new function each render
const handleClickBad = () => {
  setCount(prev => prev + 1);
};

React.memo and Memo + Callback Anti-Patterns

React.memo prevents re-renders of a component if its props haven't changed (by shallow comparison). However, combining it with inline functions or objects without useCallback/useMemo defeats its purpose because new references are created each render. A common anti-pattern is wrapping a component in React.memo but passing an inline callback—the memoization becomes useless. Always pair React.memo with stable references for the props that are functions or objects.

React.memo Anti-Pattern
// Anti-pattern: memoized component but inline callback
const Child = React.memo(({ onClick }) => {
  return <button onClick={onClick}>Click</button>;
});

function Parent() {
  return <Child onClick={() => console.log('clicked')} />; // New function each render, memo useless
}

// Correct: use useCallback
function ParentFixed() {
  const handleClick = useCallback(() => console.log('clicked'), []);
  return <Child onClick={handleClick} />;
}
Interview Tip

Interviewers often ask: 'When would you use useCallback vs useMemo?' The key distinction: useCallback returns a memoized function (for referential stability), while useMemo returns a memoized value (for expensive computations). Both depend on dependency arrays. Also, be ready to discuss that premature memoization can harm performance due to memory overhead and dependency comparison costs.

🧩Quick Check

Which of the following is a valid reason to use useMemo?

Flashcards4
06 — PATTERNS

List Performance & Virtualisation

⚡ List Performance Optimization Stack (10K rows)

When rendering large lists (10K+ rows), performance degrades quickly. Apply these optimizations in order, measuring impact between each step.

StepTechniqueImpact
1Virtualization (react-window, TanStack Virtual)Biggest win — render only visible rows
2Pagination / Infinite Scroll (50-100 rows, IntersectionObserver)Great UX — load more on demand
3React.memo + Stable Refs (wrap Row component, stable props)Incremental — avoid re-renders
4useMemo for Sort/Filter (avoid recompute on parent re-render)Incremental — memoize expensive ops
5Stable Keys (unique IDs, never array index)Correctness — prevent broken state
6CSS Containment (contain: layout style paint)Browser opt — reduce per-row work
7Defer Updates (useDeferredValue, startTransition)Advanced — keep input responsive
8Web Worker (heavy sort/filter offloaded)Last resort — non-blocking UI
Interview Tip

Always start with virtualization — it's the single biggest performance gain. Then layer on memoization and stable keys. Only reach for Web Workers if you're doing heavy computation.

Flashcards4
🧩Quick Check

You have a list of 10,000 items that users can filter by typing into a search box. Which optimization should you apply FIRST?

07 — PATTERNS

Context API & State Management

The Context API is React's built-in solution for sharing state across components without manually passing props through every level of the tree. While it's a powerful tool, understanding when and how to use it—and when to reach for external libraries like Zustand or Redux—is critical for building performant, maintainable React applications. This section covers the core concepts, common pitfalls, and advanced patterns for effective state management with Context.

Context vs Prop Drilling

Prop drilling occurs when you pass data through multiple intermediate components that don't need the data themselves, just to reach a deeply nested child. Context eliminates this by providing a way to broadcast data to any component in the tree. However, Context should not be used for every piece of state—it's best for truly global or widely shared data like themes, user authentication, or locale preferences. Overusing Context can lead to unnecessary re-renders and complexity.

Prop drilling vs Context
// Prop drilling example (avoid)
function App() {
  const user = { name: 'Alice' };
  return <Parent user={user} />;
}
function Parent({ user }) {
  return <Child user={user} />;
}
function Child({ user }) {
  return <p>{user.name}</p>;
}

// Context API example (preferred)
const UserContext = React.createContext();
function App() {
  const user = { name: 'Alice' };
  return (
    <UserContext.Provider value={user}>
      <Parent />
    </UserContext.Provider>
  );
}
function Parent() {
  return <Child />;
}
function Child() {
  const user = useContext(UserContext);
  return <p>{user.name}</p>;
}

Re-render Scope of Context

When the value passed to a Context Provider changes, all components that consume that context will re-render, even if they only use a part of the value. This is a common performance issue. To mitigate this, you can split contexts, use memoization, or restructure your components to minimize the number of consumers.

Re-render Pitfall

Every time the context value changes (e.g., a new object reference), all consumers re-render. This can cause performance problems in large trees. Always memoize the context value with useMemo to avoid unnecessary re-renders when the underlying data hasn't changed.

Splitting Contexts

Instead of one large context for all global state, split it into smaller, logically separate contexts. For example, separate contexts for user data, UI theme, and notifications. This ensures that a change in one context (e.g., theme) doesn't trigger re-renders in components that only consume user data.

Splitting contexts
// Instead of one big context:
// const AppContext = React.createContext();

// Split into multiple contexts:
const UserContext = React.createContext();
const ThemeContext = React.createContext();

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <MainContent />
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

Context + useReducer Pattern

Combining Context with useReducer provides a predictable state management pattern similar to Redux but without the external dependency. The reducer handles state transitions, and the context provides both state and dispatch to the component tree. This is ideal for medium-complexity state that needs to be shared across multiple components.

Context + useReducer
const TodoContext = React.createContext();

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.payload }];
    case 'REMOVE_TODO':
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
}

function TodoProvider({ children }) {
  const [todos, dispatch] = useReducer(todoReducer, []);
  return (
    <TodoContext.Provider value={{ todos, dispatch }}>
      {children}
    </TodoContext.Provider>
  );
}

// Usage in a component:
function TodoList() {
  const { todos, dispatch } = useContext(TodoContext);
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.text}
          <button onClick={() => dispatch({ type: 'REMOVE_TODO', payload: todo.id })}>X</button>
        </li>
      ))}
    </ul>
  );
}

When to Use Zustand/Redux Instead

While Context + useReducer works for many cases, external libraries like Zustand or Redux offer advantages for complex state: better performance (via subscriptions instead of re-rendering all consumers), middleware support, devtools, and a more structured approach. Use them when you have deeply nested updates, frequent state changes, or need to share state across unrelated parts of the app. Zustand is simpler and more lightweight; Redux is more feature-rich and opinionated.

FeatureContext + useReducerZustandRedux
BoilerplateLowLowHigh
PerformanceCan cause re-rendersSelective subscriptionsSelective subscriptions
MiddlewareManualBuilt-inRich ecosystem
DevToolsLimitedSupportedExcellent
Best forSmall to medium appsMedium to large appsLarge, complex apps

Selector Pattern

To avoid unnecessary re-renders when using Context, implement a selector pattern. Instead of consuming the entire context value, create custom hooks that return only the specific slice of state a component needs. This can be combined with useMemo or libraries like use-context-selector to further optimize.

Selector pattern with custom hooks
// Custom hook with selector
function useUser() {
  const { user } = useContext(UserContext);
  return user;
}

function useTheme() {
  const { theme } = useContext(ThemeContext);
  return theme;
}

// Component only re-renders when 'user' changes
function UserProfile() {
  const user = useUser();
  return <p>{user.name}</p>;
}

// Component only re-renders when 'theme' changes
function ThemeSwitcher() {
  const theme = useTheme();
  return <div className={theme}>...</div>;
}
🧩Quick Check

What is the main performance concern when using a single large Context Provider for all global state?

Flashcards5
08 — PATTERNS

Custom Hooks & Composition

Custom hooks are the cornerstone of reusable stateful logic in React. They allow you to extract component logic into functions that can be shared across your application. Mastering custom hooks and understanding composition patterns is essential for writing clean, maintainable, and testable React code. This section covers the rules of hooks, how to extract logic, common custom hooks like useDebounce, useFetch, and useLocalStorage, and how to test them effectively.

Rules of Hooks

React enforces two fundamental rules for hooks to ensure consistent behavior and avoid bugs. First, only call hooks at the top level of your component or custom hook—never inside loops, conditions, or nested functions. Second, only call hooks from React function components or custom hooks, not from regular JavaScript functions. Violating these rules can lead to unpredictable state and rendering issues.

  • Call hooks at the top level of your component or custom hook.
  • Call hooks only from React function components or custom hooks.
  • Do not call hooks inside conditions, loops, or nested functions.
  • Use the ESLint plugin eslint-plugin-react-hooks to enforce these rules automatically.

Extracting Stateful Logic

When you find the same stateful logic repeated across multiple components, it's time to extract it into a custom hook. For example, if several components need to track a window's width, you can create a useWindowWidth hook. This promotes DRY (Don't Repeat Yourself) principles and makes your code more modular and testable.

Example: Extracting useWindowWidth
import { useState, useEffect } from 'react';

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

// Usage in a component
function MyComponent() {
  const width = useWindowWidth();
  return <p>Window width: {width}px</p>;
}

Common Custom Hooks: useDebounce, useFetch, useLocalStorage

These three hooks are frequently used in real-world applications. useDebounce delays updating a value until after a specified delay, useful for search inputs. useFetch encapsulates data fetching logic with loading and error states. useLocalStorage syncs state with the browser's localStorage, persisting data across sessions.

Example: useDebounce, useFetch, useLocalStorage
// useDebounce
import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// useFetch
import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error('Network response was not ok');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);

  return { data, loading, error };
}

// useLocalStorage
import { useState } from 'react';

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}
Key Insight

Custom hooks are not just for reusing logic—they also enable composition. You can combine multiple hooks inside a single custom hook to create more complex behaviors. For example, a useSearch hook could internally use both useDebounce and useFetch to debounce a search query and fetch results.

Testing Custom Hooks

Testing custom hooks is crucial to ensure they work correctly in isolation. The recommended approach is to use the @testing-library/react-hooks library, which provides a renderHook function. This allows you to test hooks without needing a full component wrapper. You can verify initial values, state updates, and side effects.

Example: Testing useLocalStorage
import { renderHook, act } from '@testing-library/react-hooks';
import useLocalStorage from './useLocalStorage';

beforeEach(() => {
  localStorage.clear();
});

test('should return initial value and update localStorage', () => {
  const { result } = renderHook(() => useLocalStorage('key', 'initial'));

  expect(result.current[0]).toBe('initial');

  act(() => {
    result.current[1]('new value');
  });

  expect(result.current[0]).toBe('new value');
  expect(localStorage.getItem('key')).toBe('"new value"');
});
🧩Quick Check

Which of the following is a valid reason to create a custom hook?

Flashcards4
10 — ADVANCED

Server Components & Suspense

Server Components and Suspense represent a paradigm shift in React, enabling efficient server-side rendering and seamless async data handling. Understanding the boundary between React Server Components (RSC) and React Client Components (RCC) is crucial for building performant applications. This section covers key concepts, practical code examples, and common pitfalls to avoid during migration.

RSC vs RCC Boundary

The boundary between Server and Client Components is defined by the 'use client' directive. Server Components run exclusively on the server, reducing bundle size by excluding client-side JavaScript. Client Components run in the browser and can use hooks, event handlers, and browser APIs. A key rule: Server Components cannot import Client Components directly; they must pass data as props.

RSC vs RCC boundary example
// Server Component (no 'use client')
import ClientButton from './ClientButton';

export default function ServerComponent() {
  const data = fetchDataFromDB(); // runs on server
  return (
    <div>
      <h1>Server Rendered</h1>
      <ClientButton label="Click me" />
    </div>
  );
}

// Client Component
'use client';

export default function ClientButton({ label }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{label} ({count})</button>;
}

Server-Side Data Fetching

Server Components can directly fetch data using async/await, eliminating the need for useEffect or external state management for initial data. This reduces client-side JavaScript and improves performance. Data fetching happens during server rendering, and the result is sent as serialized props to the client.

Server-side data fetching
// Server Component with async data fetching
export default async function UserProfile({ userId }) {
  const user = await fetch(`https://api.example.com/users/${userId}`).then(res => res.json());
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}

Suspense for Async Operations

Suspense allows components to 'wait' for asynchronous operations (like data fetching or code splitting) before rendering. It works with Server Components and the use() hook to provide a declarative loading state. Suspense boundaries can be nested for granular control.

Suspense with async component
import { Suspense } from 'react';
import UserProfile from './UserProfile';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading user...</div>}>
        <UserProfile userId={123} />
      </Suspense>
    </div>
  );
}

Streaming and the use() Hook

Streaming enables progressive rendering by sending HTML chunks as they become available. The use() hook (experimental in React 18, stable in React 19) allows reading a promise directly within a component, integrating with Suspense for seamless async handling. It simplifies data fetching by removing the need for useEffect or custom hooks.

use() hook with Suspense
import { use, Suspense } from 'react';

function fetchUser(id) {
  return fetch(`https://api.example.com/users/${id}`).then(res => res.json());
}

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

export default function App() {
  const userPromise = fetchUser(1);
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserDetails userPromise={userPromise} />
    </Suspense>
  );
}

Common Migration Mistakes

  • Forgetting 'use client' directive: Using hooks or event handlers in a Server Component without the directive causes errors.
  • Mixing server and client logic: Trying to use browser APIs (like localStorage) in Server Components without proper boundaries.
  • Overusing Client Components: Moving entire pages to client-side, negating the benefits of server rendering.
  • Ignoring serialization: Passing non-serializable data (like functions or class instances) from Server to Client Components.
  • Not handling streaming correctly: Assuming all data is available immediately, leading to hydration mismatches.
Interview Tip

Be ready to explain how Server Components reduce bundle size and improve performance. A common question: 'How do you decide when to use a Server Component vs a Client Component?' Answer: Use Server Components for static data and logic that doesn't need interactivity; use Client Components for interactive UI with hooks, event handlers, or browser APIs.

🧩Quick Check

What is the primary purpose of the 'use client' directive in React Server Components?

Flashcards4
09 — ADVANCED

Testing React Components

Testing React components effectively requires a shift in mindset from testing implementation details to testing user-facing behavior. The React Testing Library (RTL) embodies this philosophy, encouraging tests that resemble how users interact with your components. This section covers core RTL concepts, query methods, event simulation, mocking strategies, async patterns, and common pitfalls like snapshot testing.

RTL Philosophy: Test Behavior, Not Implementation

RTL's guiding principle is to write tests that simulate real user interactions and verify outcomes, rather than testing internal state, lifecycle methods, or component internals. This makes tests more resilient to refactoring and provides confidence that the component works as expected from the user's perspective. For example, instead of checking if a state variable changed, you assert that a button becomes disabled or a success message appears.

Behavior vs Implementation
// ❌ Testing implementation (fragile)
const { container } = render(<Counter />);
expect(container.querySelector('span').textContent).toBe('0');

// ✅ Testing behavior (robust)
const { getByRole } = render(<Counter />);
expect(getByRole('status')).toHaveTextContent('0');

getBy vs queryBy vs findBy

RTL provides three families of query methods, each with different behavior for element existence and timing. getBy* throws an error if the element is not found, making it ideal for elements that should exist immediately. queryBy* returns null instead of throwing, useful for asserting absence. findBy* returns a promise that resolves when the element appears (after async updates), perfect for testing loading states or delayed renders.

MethodReturnsThrows if not foundUse case
getBy*elementYesElement must exist synchronously
queryBy*element or nullNoAssert element is absent
findBy*Promise<element>Yes (after timeout)Element appears after async update
Query Method Examples
// getBy - element must exist
const button = screen.getByRole('button', { name: /submit/i });

// queryBy - element may not exist
const error = screen.queryByText(/error/i);
expect(error).toBeNull();

// findBy - wait for async appearance
const success = await screen.findByText(/success/i);

userEvent vs fireEvent

While fireEvent dispatches a single DOM event, userEvent from @testing-library/user-event simulates full user interactions (e.g., typing, clicking, focusing). userEvent is preferred because it triggers multiple events in the correct order (like focus, keyDown, keyUp, change for typing), making tests more realistic and catching edge cases fireEvent might miss.

userEvent vs fireEvent
// fireEvent - low-level, single event
fireEvent.change(input, { target: { value: 'hello' } });

// userEvent - realistic interaction sequence
await userEvent.type(input, 'hello'); // triggers focus, keydown, keyup, change, etc.
Key Insight

Always prefer userEvent over fireEvent for simulating user interactions. It's more realistic and helps catch bugs that only appear with full event sequences. Install it with npm install --save-dev @testing-library/user-event.

Mocking Hooks and Context

To test components that rely on custom hooks or React Context, you can mock the hook's return value or wrap the component in a test provider. For hooks, use jest.mock to replace the hook module. For context, create a wrapper component that provides a controlled value. This isolates the component under test and allows you to verify behavior for different states.

Mocking Hooks and Context
// Mocking a custom hook
jest.mock('../hooks/useAuth', () => ({
  useAuth: () => ({ user: { name: 'Alice' }, isAdmin: true })
}));

// Testing with context wrapper
const renderWithTheme = (ui, { theme = 'light' } = {}) => {
  return render(<ThemeContext.Provider value={theme}>{ui}</ThemeContext.Provider>);
};

it('shows admin panel for admin users', () => {
  renderWithTheme(<AdminPanel />);
  expect(screen.getByText(/admin settings/i)).toBeInTheDocument();
});

Async Testing and Snapshot Pitfalls

Async testing often involves waiting for elements to appear after data fetching or state updates. Use waitFor or findBy* to handle these cases. Snapshot tests can be useful for detecting unintended UI changes, but they have pitfalls: large snapshots are hard to review, they break on trivial changes (like whitespace), and they can give a false sense of coverage. Prefer targeted assertions over snapshots for critical logic.

Async Testing and Snapshot Pitfalls
// Async test with waitFor
import { waitFor } from '@testing-library/react';

it('loads and displays data', async () => {
  render(<DataFetcher />);
  await waitFor(() => {
    expect(screen.getByText(/loaded data/i)).toBeInTheDocument();
  });
});

// Snapshot pitfall: fragile and hard to review
it('matches snapshot', () => {
  const { container } = render(<MyComponent />);
  expect(container).toMatchSnapshot(); // Breaks on any HTML change
});
🧩Quick Check

Which query method should you use to assert that an error message does NOT appear after a form submission (assuming the message might appear asynchronously)?

Flashcards5