Interview Handbook
React Deep Dive — Interview Handbook
Master React internals, hooks, performance patterns, and modern architecture — everything asked in senior React interviews.
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
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 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.
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.
// 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.
// 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')
);
}What is the primary purpose of the `key` prop in React lists?
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.
| Condition | Action | Effect |
|---|---|---|
| Element type changed (e.g., <div> → <span>) | Unmount + Remount | State LOST · child tree destroyed · effects cleaned up |
| Same type, props unchanged | Skip update | Reuse existing instance, no re-render needed |
| Same type, props changed | Update props | Same instance, state preserved, effects may re-run |
Defining a child component INSIDE a parent creates new function ref every render → React sees different 'type' → unmount+remount each time → state resets!
function Parent() {
// ❌ BAD: Child gets fresh identity each render
const Child = () => <div>...</div>;
return <Child />;
}Helps React track elements in lists. Use stable IDs (not array index!) to avoid unnecessary remounts.
What happens when React detects a type change from <div> to <span> at the same position?
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.
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.
// 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
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.
| Criteria | useState | useReducer |
|---|---|---|
| State complexity | Simple, independent values | Complex objects or multiple sub-values |
| Update logic | Direct or functional updates | Dispatching actions with a reducer |
| Number of state variables | Few (1-3) | Many or nested |
| Testability | Harder to test | Reducer is a pure function, easy to test |
| Performance | Good for small state | Better 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.
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>
);
}What happens when you call setCount(count + 1) twice in the same synchronous event handler without functional updates?
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.
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.
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.
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.
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>;
}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.
What happens if you omit the dependency array in useEffect?
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.
// 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 > benefitReferential 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.
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.
// 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} />;
}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.
Which of the following is a valid reason to use useMemo?
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.
| Step | Technique | Impact |
|---|---|---|
| 1 | Virtualization (react-window, TanStack Virtual) | Biggest win — render only visible rows |
| 2 | Pagination / Infinite Scroll (50-100 rows, IntersectionObserver) | Great UX — load more on demand |
| 3 | React.memo + Stable Refs (wrap Row component, stable props) | Incremental — avoid re-renders |
| 4 | useMemo for Sort/Filter (avoid recompute on parent re-render) | Incremental — memoize expensive ops |
| 5 | Stable Keys (unique IDs, never array index) | Correctness — prevent broken state |
| 6 | CSS Containment (contain: layout style paint) | Browser opt — reduce per-row work |
| 7 | Defer Updates (useDeferredValue, startTransition) | Advanced — keep input responsive |
| 8 | Web Worker (heavy sort/filter offloaded) | Last resort — non-blocking UI |
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.
You have a list of 10,000 items that users can filter by typing into a search box. Which optimization should you apply FIRST?
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 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.
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.
// 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.
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.
| Feature | Context + useReducer | Zustand | Redux |
|---|---|---|---|
| Boilerplate | Low | Low | High |
| Performance | Can cause re-renders | Selective subscriptions | Selective subscriptions |
| Middleware | Manual | Built-in | Rich ecosystem |
| DevTools | Limited | Supported | Excellent |
| Best for | Small to medium apps | Medium to large apps | Large, 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.
// 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>;
}What is the main performance concern when using a single large Context Provider for all global state?
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-hooksto 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.
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.
// 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];
}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.
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"');
});Which of the following is a valid reason to create a custom hook?
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.
// 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 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.
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.
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.
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.
What is the primary purpose of the 'use client' directive in React Server Components?
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.
// ❌ 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.
| Method | Returns | Throws if not found | Use case |
|---|---|---|---|
| getBy* | element | Yes | Element must exist synchronously |
| queryBy* | element or null | No | Assert element is absent |
| findBy* | Promise<element> | Yes (after timeout) | Element appears after async update |
// 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.
// 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.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 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 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
});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)?