14. Front-End Development with React
Chapters 6 and 8 built interfaces by hand: find a DOM node, change it, and remember every other node that depends on the same data. That approach collapses as an application grows, because every event handler must know the whole page. React replaces it with a different contract: you describe what the interface should look like for a given state, and the library updates the DOM for you. This chapter first covers the component model, JSX and reconciliation (14.1), then state and hooks (14.2) and the patterns that structure real applications (14.3). It continues with routing, data fetching and global state (14.4), and ends with testing, performance and server rendering (14.5). The running example is a front end for the bookshelf API built with Express in Chapter 12, written in TypeScript (Chapter 9) wherever types make the code clearer.
<a id="141-react-fundamentals"></a>
14.1 React Fundamentals
This subchapter explains the ideas everything else builds on: why declarative UI is easier to reason about than DOM manipulation, how JSX becomes JavaScript, how components receive data through props and render lists and conditions, how React turns a description of the UI into minimal DOM updates, how components are composed, and how a modern React project is set up and built.
Declarative UI and the Component Model
The problem React solves. Consider a shopping cart badge, a cart drawer and a checkout button that all depend on the same list of items. With direct DOM manipulation (Chapter 6) or jQuery (Chapter 8), every action that changes the cart must also update each of those elements:
// Imperative: every handler must know every piece of UI that depends on the cart
function addToCart(item) {
cart.push(item);
document.querySelector('#badge').textContent = cart.length; // 1. badge
const li = document.createElement('li'); // 2. drawer list
li.textContent = `${item.name} - ${item.price} EUR`;
document.querySelector('#drawer ul').append(li);
document.querySelector('#checkout').disabled = cart.length === 0; // 3. button
// Forget one of these lines (or add a fourth widget later) and the page lies to the user
}
The code describes how to move from one screen to the next. With n possible actions and m widgets, there are up to n x m transitions to get right, and each must handle every starting state. This is where most UI bugs come from: the page and the data drift apart.
Declarative UI. React inverts the approach. You write a function that, given the current data (the state), returns a description of what the whole UI should look like. When the state changes, React calls the function again and works out which DOM changes are needed. The core idea fits in one line:
UI = f(state)
A spreadsheet is a good analogy. You do not write "when A1 changes, recompute B1 and redraw it"; you write the formula B1 = A1 * 2 once, and the spreadsheet keeps it true. In React you write the "formula" for each piece of UI, and React keeps the screen consistent with the data.
// Declarative: describe the result for any cart; React handles the transitions
function CartSummary({ items }) {
return (
<aside>
<span className="badge">{items.length}</span>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name} - {item.price} EUR</li>
))}
</ul>
<button disabled={items.length === 0}>Checkout</button>
</aside>
);
}
There is now one code path, the render function, instead of one per transition. Adding a fourth widget means editing one description, not every handler.
| Aspect | Imperative DOM (Chapter 6, jQuery) | Declarative React |
|---|---|---|
| You write | The steps that turn screen A into screen B | What the screen looks like for any state |
| Source of truth | Often the DOM itself (read values back from nodes) | JavaScript state; the DOM is a projection of it |
| Adding a dependent widget | Update every handler that changes the data | Add it to one render function |
| DOM updates | Done by hand, easy to over- or under-update | Computed and batched by React |
| Testing | Requires a DOM and knowledge of node structure | Render with given props, assert on the output |
Components. A component is a reusable piece of UI that owns its markup, its logic and (optionally) its state. In modern React a component is simply a JavaScript function that returns a description of UI. Components nest inside other components, so an application becomes a tree:
<App>
+-- <Header>
| +-- <Logo>
| +-- <SearchBox> owns the text being typed
+-- <BookList> receives the books to display
| +-- <BookCard> x N one per book, each identical in structure
+-- <CartSummary> receives the cart items
Components bring the same benefits functions bring to ordinary code: you name a concept (BookCard), hide its details, and reuse it. Data flows down the tree as arguments called props; changes flow up through callbacks (see "Lifting State Up and Unidirectional Data Flow" in 14.3).
What React is, and is not. React was created at Facebook by Jordan Walke, used in production on the News Feed in 2011 and open-sourced in 2013. It is a library for building user interfaces, not a full framework. It handles components, state and rendering. Routing, data fetching, forms and styling come from other libraries or from a framework built on React, such as Next.js (14.5). React itself is split into two packages:
| Package | Role |
|---|---|
react | Components, hooks, the element model; knows nothing about browsers |
react-dom | The renderer that turns React elements into browser DOM nodes |
react-native | An alternative renderer that targets iOS and Android native views |
Because the core is renderer-agnostic, the same mental model works on the web, on mobile, in PDFs and in terminal UIs.
Key Takeaways
- Imperative UI code describes transitions between screens; the number of transitions grows quickly and they drift out of sync with the data.
- React is declarative: a component describes the UI for the current state (
UI = f(state)), and React computes the DOM updates. - A component is a function that returns a UI description; components compose into a tree.
- Data flows down through props; changes flow up through callbacks.
- React is a UI library (
react+ a renderer such asreact-dom), not a full framework.
Practice
- (Beginner) Take a small page you built with plain DOM APIs in Chapter 6 (for example a to-do list) and list every place where the same piece of data is written into the DOM. Sketch the component tree you would use for it in React.
- (Intermediate) Write two versions of a "like" button with a counter and a "you and N others liked this" label: one imperative (DOM APIs, one click handler) and one as a pure function
render(state)that returns an HTML string you assign toinnerHTML. Compare how many lines change when you add a third element that shows the count.- (Interview) "What does 'React is declarative' mean, and what does it cost?" Hint: contrast describing results with describing steps, then think about what React must do on every state change to find the differences, and where escape hatches to imperative code are still needed (focus, measurements, third-party widgets).
JSX Syntax and Transpilation
Why JSX. A component must return a description of UI. React could ask you to build that description with plain function calls, and in fact that is what happens under the hood. But a deep tree of nested calls is hard to read. JSX (JavaScript XML) is a syntax extension that lets you write the description with HTML-like tags inside JavaScript. It is not HTML and not a template language: every JSX expression is compiled into an ordinary JavaScript function call.
What JSX compiles to. A compiler (Babel, esbuild, SWC or the TypeScript compiler) rewrites JSX before the code reaches the browser. Since React 17 the default is the automatic runtime, which imports helper functions for you:
// What you write
function Greeting({ name }) {
return (
<h1 className="title">
Hello, <strong>{name}</strong>!
</h1>
);
}
// What the compiler emits (automatic runtime, simplified)
import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';
function Greeting({ name }) {
return _jsxs('h1', { // jsxs: element with several static children
className: 'title',
children: ['Hello, ', _jsx('strong', { children: name }), '!'],
});
}
The older classic runtime compiled to React.createElement(type, props, ...children) and required import React from 'react' in every file that used JSX. You will still see it in older code; the two are equivalent in behaviour.
The result of either call is a React element: a plain, immutable JavaScript object that describes a node. It is not a DOM node.
// Roughly what _jsx('strong', { children: 'Ada' }) returns
{
$$typeof: Symbol.for('react.transitional.element'), // marks it as a React element (security check)
type: 'strong', // a tag name, or a component function
key: null,
props: { children: 'Ada' },
}
Embedding JavaScript. Curly braces switch from JSX back to JavaScript. Any expression is allowed inside them (variables, calls, ternaries, map), but not statements such as if or for:
const user = { first: 'Ada', last: 'Lovelace', avatarUrl: '/img/ada.png' };
const isAdmin = true;
const card = (
<div className="card">
<img src={user.avatarUrl} alt={`${user.first} ${user.last}`} /> {/* attribute expression */}
<p>{user.first.toUpperCase()}</p> {/* any expression */}
<p>{isAdmin ? 'Administrator' : 'Member'}</p> {/* ternary, not if */}
</div>
);
Tags decide the type. A lowercase tag (<div>, <button>) is a built-in DOM element and becomes a string type. A capitalised tag (<BookCard>) refers to a variable in scope, normally a component. This is why component names must start with an uppercase letter.
JSX is not HTML. The main differences:
| HTML | JSX | Reason |
|---|---|---|
class="x" | className="x" | class is a reserved word in JavaScript; JSX follows DOM property names |
for="email" | htmlFor="email" | for is reserved |
onclick="go()" | onClick={go} | Event props are camelCase and take a function, not a string |
style="color: red; font-size: 12px" | style={{ color: 'red', fontSize: 12 }} | Style is an object with camelCase keys; numbers get px |
<br>, <img src="a.png"> | <br />, <img src="a.png" /> | Every element must be closed |
tabindex, maxlength | tabIndex, maxLength | Most attributes are camelCase (aria-* and data-* keep dashes) |
<!-- comment --> | {/* comment */} | Comments are JavaScript comments inside braces |
One root per expression. A JSX expression compiles to a single function call, so it must have a single root. When you do not want an extra wrapper <div> in the DOM, use a fragment:
function Definition({ term, meaning }) {
return (
<> {/* fragment: groups children without adding a DOM node */}
<dt>{term}</dt>
<dd>{meaning}</dd>
</>
);
}
What gets rendered. Strings and numbers render as text. null, undefined, true and false render nothing, which makes conditions convenient. Arrays render each item. Objects (other than elements) throw an error. Watch out for 0: it is a number, so it is rendered (see "Conditional and List Rendering with Keys").
Escaping and XSS. Text inserted with {} is always escaped, so a book title such as <img src=x onerror=alert(1)> is shown literally rather than executed. This protects against most cross-site scripting by default. The only way to inject raw HTML is the deliberately ugly dangerouslySetInnerHTML={{ __html: html }}, which you should use only with content you have sanitised (for example with DOMPurify).
Spreading props. {...obj} passes every property of an object as props, which is useful when wrapping native elements:
const inputProps = { type: 'email', required: true, placeholder: 'you@example.com' };
<input {...inputProps} className="field" /> // later props override earlier ones
Key Takeaways
- JSX is syntax sugar compiled into
jsx()calls (automatic runtime) orReact.createElement()(classic runtime); the browser never sees JSX. - A JSX expression produces a React element, a plain object describing a node, not a DOM node.
- Curly braces embed JavaScript expressions; lowercase tags are DOM elements, capitalised tags are components.
- JSX uses DOM property names (
className,htmlFor, camelCase events and styles) and requires closed tags and a single root (use fragments). - Inserted text is escaped automatically; raw HTML requires
dangerouslySetInnerHTMLand sanitisation.
Practice
- (Beginner) Convert this HTML to valid JSX:
<label for="q" class="lbl">Search</label><input id="q" tabindex="1" style="width: 200px; margin-top: 4px"><br>.- (Intermediate) Paste a component into the Babel REPL (or run
npx esbuild file.jsx --jsx=automatic) and read the output. Rewrite the component by hand usingjsx/jsxsfromreact/jsx-runtime, then do it again withReact.createElementand compare.- (Interview) "Is JSX safe against XSS? Where can it still go wrong?" Hint: consider text escaping,
dangerouslySetInnerHTML,href={userInput}with ajavascript:URL, and data passed to third-party libraries that write to the DOM.
Function Components and Props
Components as functions. A component is a JavaScript function that takes one argument, an object called props (properties), and returns JSX. React calls it whenever it needs the latest description of that part of the UI. You never call a component yourself; you use it as a tag, and React decides when to call it.
// BookCard.tsx
type BookCardProps = {
title: string;
author: string;
year?: number; // optional prop
onSelect: (title: string) => void;
};
export function BookCard({ title, author, year, onSelect }: BookCardProps) {
return (
<article className="book-card" onClick={() => onSelect(title)}>
<h2>{title}</h2>
<p>
{author}
{year !== undefined && <span> ({year})</span>}
</p>
</article>
);
}
// Using it: attributes become the props object
<BookCard title="Dune" author="Frank Herbert" year={1965} onSelect={(t) => console.log(t)} />
// React calls BookCard({ title: 'Dune', author: 'Frank Herbert', year: 1965, onSelect: fn })
String props can be written as quoted attributes. Every other value (numbers, booleans, objects, functions) goes inside braces. A prop written without a value, as in <Toggle checked />, means checked={true}.
Props are read-only. A component must never modify its props. Think of props as the arguments of a function call: the caller owns them. If a component needs a value that changes over time, that value is state (14.2), owned by the component or by an ancestor that passes it down together with a callback to change it.
function Price({ amount }) {
amount = amount * 1.22; // wrong: reassigning the parameter is local but misleading
// props.amount = ... // wrong: props objects are frozen in development and React may reuse them
const withVat = amount * 1.22; // right: derive a new value
return <span>{withVat.toFixed(2)} EUR</span>;
}
Default values and the rest of the props. Use destructuring defaults, and collect the remaining props with a rest parameter to forward them:
type ButtonProps = React.ComponentProps<'button'> & { variant?: 'primary' | 'ghost' };
export function Button({ variant = 'primary', className = '', ...rest }: ButtonProps) {
// ...rest contains onClick, disabled, type, aria-* and any other native button prop
return <button className={`btn btn-${variant} ${className}`} {...rest} />;
}
Callback props. Functions passed as props are how a child tells its parent that something happened. By convention the prop is named onSomething and the parent's handler handleSomething:
function Rating({ value, onChange }) {
return (
<div role="radiogroup" aria-label="Rating">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
role="radio"
aria-checked={n === value}
onClick={() => onChange(n)} // the child reports; the parent decides what to do
>
{n <= value ? '*' : '.'}
</button>
))}
</div>
);
}
Components must be pure. React assumes that rendering is a pure calculation: given the same props, state and context, a component returns the same JSX and changes nothing outside itself. Pure rendering is what lets React render a component twice, pause a render or throw one away (see "Suspense and Concurrent Rendering" in 14.5). The rules:
- Do not mutate variables or objects that existed before the render (module variables, props, state).
- Do not perform side effects during render: no
fetch, no timers, nodocument.title = ..., no logging to analytics. Those belong in event handlers or effects (14.2). - Creating and mutating local variables inside the render is fine.
let renderCount = 0;
function Impure() {
renderCount++; // side effect: result depends on how often React renders
return <p>Rendered {renderCount} times</p>;
}
In development, <StrictMode> deliberately renders each component twice to expose impure code like this.
Special props. key is consumed by React (see the next topic) and is never passed to your component. Since React 19, ref is passed to function components as an ordinary prop (see "useRef and DOM Access" in 14.2); older code used forwardRef for this. children is the content between the opening and closing tags (see "Component Composition and Children").
Typing props. In TypeScript, describe props with a type or interface as above. React.ComponentProps<'input'> gives you all the props of a native element. The runtime propTypes checks of older React versions are ignored since React 19; TypeScript is the standard way to document and check props.
Key Takeaways
- A function component receives a single
propsobject and returns JSX; React, not you, decides when to call it. - Props are read-only inputs owned by the parent; derive new values instead of modifying them.
- Callback props (
onX) let children report events to their parents. - Rendering must be pure: no mutations of pre-existing data and no side effects during render.
keyis reserved for React;refis a regular prop since React 19; type props with TypeScript.
Practice
- (Beginner) Write an
Avatarcomponent with propsname,srcand an optionalsize(default48) that renders an<img>with a sensiblealt. Use it three times with different sizes.- (Intermediate) Write a typed
TextFieldcomponent that renders a<label>and an<input>, accepts alabeland an optionalerrorstring, forwards every other native input prop to the<input>, and links the error message witharia-describedby.- (Interview) "Why must React components be pure, and what happens if they are not?" Hint: think about StrictMode double rendering, concurrent rendering discarding work, memoisation skipping renders, and server rendering running the same code on another machine.
Conditional and List Rendering with Keys
Conditions are just JavaScript. JSX has no v-if or *ngIf directive. You choose what to render with ordinary JavaScript, and the choice depends on how much of the output changes.
function LoanStatus({ book, currentUser }) {
// 1. Early return: the whole output differs
if (!book) return <p>Book not found.</p>;
return (
<section>
<h2>{book.title}</h2>
{/* 2. Ternary: choose between two alternatives */}
{book.borrowedBy ? <p>On loan until {book.dueDate}</p> : <p>Available</p>}
{/* 3. Logical AND: render something or nothing */}
{book.borrowedBy === currentUser.id && <button>Return book</button>}
</section>
);
}
A component can also return null to render nothing at all. For more than two alternatives, compute the element in a variable before the return, or use an object lookup:
const badges = {
available: <span className="badge ok">Available</span>,
loaned: <span className="badge warn">On loan</span>,
lost: <span className="badge err">Lost</span>,
};
function StatusBadge({ status }) {
return badges[status] ?? null;
}
The && and zero pitfall. a && b evaluates to a when a is falsy. React renders nothing for false, null and undefined, but it renders the number 0:
{items.length && <CartList items={items} />} // shows a stray "0" when the cart is empty
{items.length > 0 && <CartList items={items} />} // correct: the condition is a boolean
Rendering lists with map. To render a collection, transform the data array into an array of elements:
function BookList({ books }) {
if (books.length === 0) return <p>No books yet.</p>;
return (
<ul>
{books
.filter((b) => !b.archived) // ordinary array methods work
.map((b) => (
<li key={b.id}>{b.title} by {b.author}</li>
))}
</ul>
);
}
Why keys exist. When a list re-renders, React must match each new element to an element from the previous render to decide what to keep, move, create or remove. Without extra information it can only match by position. A key is a string or number that identifies an item among its siblings, like a name tag at a conference: it lets React recognise the same person even after everyone has changed seats.
Previous render Next render (new book inserted at the top)
key=7 "Dune" key=9 "Neuromancer" -> new: create one <li>
key=3 "Emma" key=7 "Dune" -> same key: keep DOM node and state, move it
key=3 "Emma" -> same key: keep DOM node and state
Why not the array index. Using key={index} is equivalent to having no key: identity follows position. It works for static lists that never reorder, but it breaks as soon as items are inserted, removed or sorted, because React then reuses the wrong component instance and its state:
// Each row has an uncontrolled <input> for a note. Insert a book at the top with index keys:
{books.map((b, i) => (
<li key={i}> {/* bug: the row at index 0 keeps the note typed for the old first book */}
{b.title} <input placeholder="Note" />
</li>
))}
// Fix: key={b.id}; the note stays attached to the book it was typed for
Rules for keys:
| Rule | Explanation |
|---|---|
| Unique among siblings | Keys only need to be unique within one list, not globally |
| Stable | The same item must get the same key on every render; never use Math.random() or crypto.randomUUID() during render |
| From the data | Use database IDs (book.id, ISBN); generate IDs when creating items, not when rendering them |
| Index only as last resort | Acceptable for static, never-reordered lists without state |
| On the outermost element | Put the key on the element returned by map, not on a child inside it |
When each item renders several elements without a wrapper, use the long form of the fragment, which accepts a key: <Fragment key={term.id}><dt>...</dt><dd>...</dd></Fragment>.
Keys as a reset button. A key also works outside lists. Changing an element's key tells React that it is a different component, so the old instance is unmounted and a fresh one with fresh state is mounted. This is the cleanest way to reset a form when the edited record changes:
<EditBookForm key={selectedBook.id} book={selectedBook} /> // switching books resets every field
Key Takeaways
- Use early returns, ternaries,
&&andnullfor conditions; there are no template directives. - Make
&&conditions boolean (count > 0 &&) to avoid rendering a stray0. - Render lists with
map; each element needs akeythat is unique among siblings and stable across renders. - Index keys tie identity to position and cause state to jump between items when the list changes.
- Changing a component's
keyresets it: React unmounts the old instance and mounts a new one.
Practice
- (Beginner) Render an array of
{ id, title, year }books as a table, showing "Classic" next to every book published before 1970 and a "No results" message when the array is empty.- (Intermediate) Build a list where each row has a text input and a "Move up" button. Use index keys, type text in a few rows, reorder them and observe the bug. Then switch to stable IDs and explain what changed in terms of component identity.
- (Interview) "Why does React need keys, and why is the array index a bad key?" Hint: explain how React matches children between renders, what happens to component state and DOM nodes when matching follows position, and when an index key is actually harmless.
The Virtual DOM and Reconciliation
The dilemma. Declarative UI means re-running the component function whenever state changes. If React threw away the old DOM and built a new one every time, it would be slow, and it would also destroy things the user cares about: text selection, focus, scroll position, the caret inside an input, playing videos. React needs to re-describe everything but change only what is different.
The virtual DOM. React elements are cheap JavaScript objects. On every update React builds a new tree of elements, compares it with the previous tree, and applies only the differences to the real DOM. The in-memory tree is commonly called the virtual DOM, and the comparison process is called reconciliation. An analogy: an architect does not demolish a building to move a door. They compare the old and new blueprints and give the builders a short list of changes.
state change
|
v
RENDER PHASE (pure, can be paused or discarded)
call the components whose state, props or context changed
-> new element tree
-> diff against the previous tree (reconciliation)
-> list of DOM mutations
|
v
COMMIT PHASE (synchronous, cannot be interrupted)
apply the mutations to the real DOM
attach refs, run layout effects
|
v
browser paints
|
v
run effects (useEffect)
Rendering is not updating the DOM. "React re-rendered my component" means React called the function. If the returned elements are identical to the previous ones, the commit phase makes no DOM changes at all. This distinction matters when you investigate performance (14.5).
What triggers a render. A component renders when:
- It mounts for the first time.
- Its own state changes (a
setStatecall with a new value). - Its parent renders. By default, children re-render with their parent even if their props did not change.
- A context it reads changes (see "useContext and Prop Drilling" in 14.2).
The diffing heuristics. Finding the minimum set of changes between two arbitrary trees costs roughly O(n^3), which is far too slow. React uses two assumptions that make diffing O(n):
| Situation | What React does |
|---|---|
Element type changed at a position (<div> to <section>, or <LoginForm> to <Dashboard>) | Tears down the whole old subtree (DOM nodes and component state) and builds the new one |
| Same DOM element type | Keeps the DOM node, updates only the changed attributes, then recurses into children |
| Same component type | Keeps the component instance and its state, calls it with the new props, then recurses |
| List of children | Matches children by key (by position if there are no keys), then moves, inserts or removes |
// Before // After
<div className="card"> <div className="card active"> -> update one attribute
<h2>Dune</h2> <h2>Dune</h2> -> unchanged, nothing to do
<p>1965</p> <span>1965</span> -> type changed: remove <p>, create <span>
</div> </div>
State lives at a position in the tree. A consequence of these rules is that React associates state with a component's position in the tree (and its key), not with the JSX variable. Rendering the same component type at the same position keeps its state; rendering a different type, or the same type with a different key, resets it:
{isEditing ? <BookForm /> : <BookPreview />} // switching types: BookForm state is discarded
{showDetails && <Counter />} // removing it from the tree discards its state
Never define a component inside another component. The inner function is a new type on every render, so its state is reset every time:
function Parent() {
function Child() { /* ... */ } // wrong: a brand-new component type on every Parent render
return <Child />; // React sees a different type each time and remounts it
}
Fiber and scheduling. Since React 16, the reconciler is called Fiber. Each element in the tree is represented by a fiber, a unit of work that records the component's type, state, effects and links to its parent, child and sibling. Because work is split into small units, React can pause rendering to handle a more urgent update (such as a keystroke), then resume or discard the interrupted work. This is the foundation of concurrent rendering (14.5). Updates are tagged with priorities ("lanes"): a click or keystroke is urgent, while a transition that filters a long list can wait.
Is the virtual DOM "fast"? It is fast enough, and it makes declarative code practical, but it is not free: React still runs component functions and diffs trees. Other libraries take different routes. Svelte compiles components into direct DOM updates. SolidJS and Vue's Vapor mode use fine-grained signals that update only the nodes depending on a changed value. React instead reduces unnecessary work with memoisation (14.2) and, since 2025, the React Compiler (14.5).
Key Takeaways
- React re-describes the UI on every update and diffs the new element tree against the previous one (reconciliation), applying only the differences to the DOM.
- Updates have a render phase (pure, interruptible) and a commit phase (DOM mutations, synchronous); rendering does not always mean DOM changes.
- A component renders on mount, on its own state change, when its parent renders, and when a context it reads changes.
- Different element type at a position means teardown; same type means update in place; lists are matched by key.
- State belongs to a position in the tree; never define components inside other components.
Practice
- (Beginner) Write a component that toggles between
<input placeholder="A" />and<input placeholder="B" />with a button, type into the field and toggle. Then wrap one of them in a<div>and repeat. Explain the difference using the diffing rules.- (Intermediate) Add
console.logcalls to a parent, a child that receives props, and a child that receives none. Change the parent's state and record which ones log. Then open the browser DevTools "Elements" panel with "flash on DOM change" (or React DevTools "Highlight updates") and compare which components rendered with which DOM nodes actually changed.- (Interview) "Explain reconciliation. Why is React's diff O(n) instead of the theoretical O(n^3)?" Hint: mention the two heuristics (different types produce different trees; keys identify children) and what is lost by accepting them.
Component Composition and Children
Composition over inheritance. Object-oriented UI toolkits often build specialised widgets by subclassing (class PrimaryButton extends Button). React deliberately does not. Instead, components are combined by composition: a component renders other components and passes them data and content. The React team has noted that, across thousands of components at Meta, they found no case where component inheritance was the better choice.
The children prop. Whatever you put between a component's opening and closing tags is passed to it as props.children. This lets generic "box" components wrap content they know nothing about, like a picture frame that does not care which picture it holds:
type CardProps = { title: string; children: React.ReactNode }; // ReactNode: anything renderable
function Card({ title, children }: CardProps) {
return (
<section className="card">
<h2 className="card-title">{title}</h2>
<div className="card-body">{children}</div> {/* the caller decides what goes here */}
</section>
);
}
// Usage: the content can be any JSX, including other components
<Card title="Currently reading">
<BookCover isbn="9780441172719" />
<p>Page 212 of 612</p>
</Card>
Multiple slots. When a layout has several holes, pass elements through named props. Elements are just values, so any prop can hold JSX:
function PageLayout({ header, sidebar, children, footer }) {
return (
<div className="layout">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{children}</main>
<footer>{footer}</footer>
</div>
);
}
<PageLayout
header={<NavBar user={user} />}
sidebar={<GenreFilter value={genre} onChange={setGenre} />}
footer={<small>Bookshelf 2026</small>}
>
<BookList books={books} />
</PageLayout>
Specialisation. A "special case" component is a generic component rendered with some props fixed. This replaces subclassing:
function Dialog({ title, message, children }) {
return (
<div role="dialog" aria-labelledby="dlg-title" className="dialog">
<h2 id="dlg-title">{title}</h2>
<p>{message}</p>
{children}
</div>
);
}
function ConfirmDeleteDialog({ bookTitle, onConfirm, onCancel }) {
return (
<Dialog title="Delete book?" message={`"${bookTitle}" will be removed permanently.`}>
<button onClick={onCancel}>Cancel</button>
<button className="danger" onClick={onConfirm}>Delete</button>
</Dialog>
);
}
Composition reduces prop drilling. If an intermediate component only forwards props to a deep child, let the top component build the child itself and pass it down as children. The intermediate component no longer needs to know about the data:
// Before: Layout receives `user` only to hand it to UserMenu
<Layout user={user} />
// After: App builds UserMenu; Layout just places what it is given
<Layout menu={<UserMenu user={user} />} />
Compound components. Related components that share implicit state can be designed to be used together, as <select> and <option> are in HTML: <Tabs>, <Tabs.List>, <Tabs.Tab>, <Tabs.Panel>. The parent shares state with its parts through context (14.2). Many component libraries use this pattern (14.3).
Treat children as opaque. It can be one element, a string, an array or nothing. Older code inspected or modified it with React.Children.map and cloneElement; this is fragile (it breaks when a child is wrapped in a fragment or another component) and is now discouraged. Prefer explicit props, render functions (14.3) or context.
Key Takeaways
- React combines components by composition, not inheritance.
childrenholds the content between a component's tags; generic wrappers render it without knowing what it is.- Any prop can carry JSX, which gives a component several named slots.
- Specialised components are generic components with some props fixed.
- Passing pre-built elements down reduces prop drilling; avoid inspecting or cloning
children.
Practice
- (Beginner) Write a
Panelcomponent with atitleprop andchildren, plus an optionalactionsslot rendered in the panel's header. Use it to show a book's description with "Edit" and "Delete" buttons as actions.- (Intermediate) Refactor a three-level tree where
ApppassesthemeanduserthroughLayoutandSidebartoAvatar. Remove the drilling using only composition (no context) and count how many components still need to know aboutuser.- (Interview) "Why does React favour composition over inheritance? Give an example where a junior developer might reach for inheritance." Hint: show a specialised button or dialog both ways and discuss coupling, flexibility and what changes when a new variant is needed.
Project Setup and Build Tooling
Why tooling is needed. Browsers do not understand JSX or TypeScript. A real application also has hundreds of modules and npm dependencies, CSS and images, and it needs a fast edit-refresh loop during development plus small, cached files in production. A React toolchain provides:
| Need | Provided by |
|---|---|
| Transform JSX and TypeScript to JavaScript | esbuild, SWC, Babel, tsc |
| Dev server with instant updates that preserve component state | Hot Module Replacement (HMR) with React Fast Refresh |
Resolve import 'react' from node_modules | A bundler or dev server (Vite, webpack, Rspack, Turbopack) |
| Production bundle: minified, tree-shaken, split into chunks, hashed file names | Rollup/Rolldown (in Vite), webpack, Rspack, Turbopack |
| Type checking and linting | TypeScript, ESLint with eslint-plugin-react-hooks |
The history in one paragraph. For years the standard starter was Create React App (CRA), a webpack-based CLI. It became slow and unmaintained, and the React team officially deprecated it in February 2025. Today the React documentation recommends starting with a framework (Next.js, React Router in framework mode, Expo for mobile) when you need routing, data loading and server rendering, or with Vite for a plain client-side single-page application (SPA). This chapter uses Vite for the SPA and Next.js for server rendering (14.5).
Creating a Vite project.
npm create vite@latest bookshelf-web -- --template react-ts # React + TypeScript template
cd bookshelf-web
npm install
npm run dev # dev server on http://localhost:5173 with HMR
The generated project:
bookshelf-web/
+-- index.html entry point: Vite serves it and injects the module script
+-- package.json scripts: dev, build, preview, lint
+-- vite.config.ts plugins (React), dev server, build options
+-- tsconfig.json TypeScript settings ("jsx": "react-jsx")
+-- eslint.config.js flat ESLint config with React Hooks rules
+-- public/ copied as-is (favicon, robots.txt)
+-- src/
+-- main.tsx creates the React root
+-- App.tsx root component
+-- App.css, index.css
+-- assets/ images imported from code (get hashed names)
The entry point. index.html contains an empty container. main.tsx creates a root attached to it and renders the application:
<!-- index.html -->
<body>
<div id="root"></div> <!-- React owns everything inside -->
<script type="module" src="/src/main.tsx"></script>
</body>
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css'; // CSS imports are bundled too
createRoot(document.getElementById('root')!).render( // React 18+ root API (enables concurrent features)
<StrictMode> {/* dev-only checks: double render, double effects */}
<App />
</StrictMode>,
);
Talking to the Express API in development. The Vite dev server (port 5173) and the Express API from Chapter 12 (port 3000) are different origins, so browser requests would need CORS. A dev-server proxy avoids this: the browser calls /api/... on port 5173 and Vite forwards the request to Express.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()], // JSX transform + Fast Refresh
server: {
proxy: {
'/api': 'http://localhost:3000', // /api/v1/books -> http://localhost:3000/api/v1/books
},
},
});
Environment variables. Vite exposes variables whose names start with VITE_ through import.meta.env. They are inlined into the bundle at build time, so they are public: never put secrets in them.
# .env.production
VITE_API_BASE_URL=https://api.bookshelf.example
const API = import.meta.env.VITE_API_BASE_URL ?? '/api'; // also: import.meta.env.DEV, .PROD, .MODE
Building for production.
npm run build # tsc type check + vite build -> dist/
npm run preview # serve dist/ locally to check the production build
dist/
+-- index.html
+-- assets/
+-- index-B3k9xQ1f.js content hash in the name: cache forever, a new deploy gets a new name
+-- index-Dq7mZp2a.css
dist/ is a set of static files. Serve it from a CDN, from Nginx, or from Express with express.static plus a fallback to index.html for client-side routes (see "Serving SPAs and API Gateways" in 12.3).
Recommended additions. TypeScript in strict mode, ESLint with eslint-plugin-react-hooks (catches violations of the Rules of Hooks, 14.2), Prettier for formatting, Vitest and React Testing Library (14.5), and React DevTools in the browser. The React Compiler (14.5) can be added as a Babel plugin when you want automatic memoisation.
Key Takeaways
- A toolchain transforms JSX/TypeScript, serves modules with HMR in development and produces hashed, minified, split bundles for production.
- Create React App is deprecated; use Vite for a client-side SPA or a framework (Next.js, React Router) when you need routing and server rendering built in.
createRoot(container).render(<App />)starts the application;<StrictMode>adds development-only checks.- Use the Vite dev proxy to call a local API without CORS;
VITE_variables are public and inlined at build time. - The production build is static files that any web server or CDN can host, with a fallback to
index.htmlfor client routes.
Practice
- (Beginner) Create a Vite React + TypeScript project, replace
Appwith a component that renders a hard-coded list of three books, and run bothnpm run devandnpm run build && npm run preview. Inspect the files indist/.- (Intermediate) Configure the dev proxy to the Chapter 12 Express API, fetch
/api/v1/booksonce fromAppwithfetchand log the result. Then build the app and servedist/from the Express server with a history fallback, so the API and the front end share one origin in production.- (Interview) "Why did the React team deprecate Create React App, and what would you choose for a new project?" Hint: consider build speed, maintenance, the lack of routing and data fetching in a bare SPA, and when a framework's server rendering is or is not worth its extra complexity.
<a id="142-state-and-hooks"></a>
14.2 State and Hooks
Props let a parent configure a child, but an interactive UI also needs memory: the text typed so far, whether a menu is open, the data loaded from the server. This subchapter introduces hooks, the functions that give function components state, side effects, references and shared context. It explains how the lifecycle of a component maps onto them, how to write your own hooks, and how the older class-based API expressed the same ideas.
Local State with useState
Why a normal variable is not enough. A first attempt at a counter:
function Counter() {
let count = 0;
return <button onClick={() => { count++; }}>Clicked {count} times</button>;
}
Clicking changes count, but nothing happens on screen, for two reasons. Local variables are re-created every time React calls the function, so the value would be lost anyway. And changing a local variable does not tell React that it should render again. State solves both: it is memory that React keeps between renders, plus a setter that schedules a new render.
useState. useState(initialValue) returns a pair: the current value and a function to replace it.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0); // array destructuring: name the pair as you like
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
On the first render React stores 0 and returns it. When you call setCount(1), React stores 1 and schedules a render. On that render useState(0) ignores its argument and returns the stored 1. Each component instance has its own state: two <Counter /> elements count independently.
State is a snapshot. Within one render, count is a constant. Calling the setter does not change the variable you already have; it requests a new render in which count will have the new value. Think of each render as a photograph: handlers created during that render see the values in that photograph.
function handleClick() {
setCount(count + 1); // count is 0 in this render -> request 1
setCount(count + 1); // count is still 0 -> request 1 again
setCount(count + 1); // still 0 -> request 1
console.log(count); // 0: the variable never changes inside this render
}
// Result after the click: 1, not 3
Updater functions. When the next state depends on the previous one, pass a function. React queues the functions and applies them in order to the latest value:
function handleClick() {
setCount((c) => c + 1); // 0 -> 1
setCount((c) => c + 1); // 1 -> 2
setCount((c) => c + 1); // 2 -> 3
}
Batching. React groups all state updates made during one event (and, since React 18, inside timeouts, promises and native event handlers too) and renders once at the end. Three setter calls in a handler cause one render, not three.
Objects and arrays: replace, never mutate. React decides whether to re-render by comparing the old and new state with Object.is. If you mutate an object and set it again, it is the same reference, React sees no change, and the screen does not update. Always create a new object or array:
const [book, setBook] = useState({ title: 'Dune', author: 'Frank Herbert', tags: ['sci-fi'] });
// Wrong: mutation, same reference, no re-render (and a corrupted snapshot)
book.title = 'Dune Messiah';
setBook(book);
// Right: copy with the change
setBook({ ...book, title: 'Dune Messiah' });
setBook((b) => ({ ...b, tags: [...b.tags, 'classic'] })); // add to a nested array
Common immutable array operations:
| Goal | Avoid (mutates) | Use (returns a new array) |
|---|---|---|
| Add | push, unshift | [...arr, item], [item, ...arr] |
| Remove | splice, pop | arr.filter((x) => x.id !== id) |
| Replace one | arr[i] = x | arr.map((x) => (x.id === id ? { ...x, done: true } : x)) |
| Sort or reverse | sort, reverse | arr.toSorted(cmp), arr.toReversed(), or [...arr].sort(cmp) |
For deeply nested state, the Immer library (useImmer) lets you write mutating code on a draft and produces the immutable copy for you.
Lazy initial state. The argument of useState is evaluated on every render even though it is only used on the first one. If computing it is expensive, pass a function, which React calls only once:
const [draft, setDraft] = useState(() => JSON.parse(localStorage.getItem('draft') ?? '{}'));
Designing state. Most state bugs come from storing too much. Guidelines:
- Do not store what you can compute. If you have
booksandquery, computevisibleBooksduring render instead of keeping it in state and synchronising it. - Avoid duplication. Store
selectedId, not a copy of the selected object that can go stale when the list changes. - Group values that change together (
{ x, y }of a pointer), and keep unrelated values in separateuseStatecalls. - Avoid contradictions. Instead of
isLoadingandisErrorbooleans that can both be true, use onestatus: 'idle' | 'loading' | 'error' | 'success'.
function BookSearch({ books }) {
const [query, setQuery] = useState('');
const visible = books.filter((b) => // derived during render, never stored
b.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search" />
<p>{visible.length} of {books.length} books</p>
</>
);
}
Key Takeaways
- State is memory that React preserves between renders; calling its setter schedules a new render.
- State is a snapshot: the variable does not change during the current render; use updater functions (
setX((x) => ...)) when the next value depends on the previous one. - Updates in the same event are batched into one render.
- Never mutate state objects or arrays; create new ones so that
Object.isdetects the change. - Keep state minimal: derive what you can compute, avoid duplicates and contradictory flags.
Practice
- (Beginner) Build a reading tracker with "+1 page" and "+10 pages" buttons, a reset button and a progress bar for a 612-page book. Make "+10" use an updater function and verify that clicking it quickly never loses pages.
- (Intermediate) Build a to-read list with add, remove, mark-as-read and "sort by title" features, using only immutable updates. Store only the minimum state and derive the "N unread" count.
- (Interview) "Why does
setCount(count + 1)called three times in a row only increment once?" Hint: explain state as a per-render snapshot, the queue of updates, batching, and how updater functions read the pending value.
Side Effects with useEffect
What a side effect is. Rendering must be pure (14.1), yet applications must talk to the outside world: fetch data, subscribe to a WebSocket, start a timer, set document.title, integrate a map widget. Many of these happen in response to a specific user action and belong in event handlers. The rest must happen because the component is on screen, whatever caused it to appear. useEffect is the place for this second kind: code that synchronises a component with an external system.
The anatomy.
import { useEffect, useState } from 'react';
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
// 1. Setup: runs after React has committed the render to the screen
const connection = createConnection(roomId);
connection.on('message', (m) => setMessages((prev) => [...prev, m]));
connection.connect();
// 2. Cleanup: runs before the next setup and when the component unmounts
return () => connection.disconnect();
}, [roomId]); // 3. Dependencies: re-synchronise only when roomId changes
return <MessageList messages={messages} />;
}
mount (roomId = "general") -> setup("general")
render with same roomId -> nothing (dependencies unchanged)
roomId changes to "travel" -> cleanup("general") then setup("travel")
unmount -> cleanup("travel")
The dependency array. It lists every reactive value (props, state, and values derived from them) that the effect reads. React compares each entry with Object.is against the previous render:
| Form | When the effect runs |
|---|---|
useEffect(fn) | After every render |
useEffect(fn, []) | Once after mount (cleanup on unmount) |
useEffect(fn, [a, b]) | After mount and after any render where a or b changed |
Do not "lie" about dependencies to make an effect run less often. A missing dependency means the effect keeps using a stale value from an old render. The react-hooks/exhaustive-deps lint rule finds these mistakes; when it complains, change the code (move a function inside the effect, use an updater function) rather than silencing it.
Fetching data in an effect, correctly. Responses can arrive out of order: the user types "du", then "dune", and the slow response for "du" arrives last and overwrites the correct results. The cleanup function fixes this race condition:
function BookResults({ query }) {
const [books, setBooks] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController(); // one controller per effect run
fetch(`/api/v1/books?author=${encodeURIComponent(query)}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((body) => setBooks(body.data))
.catch((err) => {
if (err.name !== 'AbortError') setError(err); // aborts are expected, not errors
});
return () => controller.abort(); // query changed or unmounted: cancel
}, [query]);
if (error) return <p role="alert">Could not load books.</p>;
return <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}
In real applications prefer a data library or framework loader for fetching (14.4); they handle caching, deduplication and races for you.
Strict Mode runs effects twice. In development, <StrictMode> mounts every component, immediately unmounts it, and mounts it again. Every effect therefore runs setup, cleanup, setup. This is deliberate: if your effect breaks when it runs twice (two WebSocket connections, a doubled analytics event), it also breaks in production when a user navigates away and back. The fix is a correct cleanup, not disabling Strict Mode.
You might not need an effect. Effects are an escape hatch, and overusing them is the most common React mistake:
| Situation | Instead of an effect |
|---|---|
| Compute a value from props or state | Compute it during render (const total = items.reduce(...)) |
| Expensive computation from props | useMemo (see "useMemo and useCallback") |
| Reset all state when a prop changes | Give the component a key (14.1) |
| React to a user action (submit, click) | Put the code in the event handler |
| Notify a parent about a change | Call the parent's callback in the same event handler |
| Subscribe to an external store | useSyncExternalStore (14.4) |
// Wrong: an extra render and a moment of stale UI
const [fullName, setFullName] = useState('');
useEffect(() => setFullName(`${first} ${last}`), [first, last]);
// Right
const fullName = `${first} ${last}`;
useLayoutEffect. useEffect runs after the browser paints. When an effect must measure the DOM and adjust it before the user sees anything (positioning a tooltip from its measured height), use useLayoutEffect, which runs after DOM mutations but before paint. It blocks painting, so use it only when you see flicker.
Reading the latest value without re-running: useEffectEvent. Sometimes an effect needs a value but should not re-synchronise when it changes, for example logging a visit with the current theme without reconnecting when the theme changes. React 19.2 provides useEffectEvent for this: it wraps a function that always sees the latest props and state and is not a dependency.
const onConnected = useEffectEvent(() => showToast(`Connected to ${roomId}`, theme));
useEffect(() => {
const c = createConnection(roomId);
c.on('connected', onConnected); // reads the latest theme without being a dependency
c.connect();
return () => c.disconnect();
}, [roomId]); // theme changes do not reconnect
Key Takeaways
- Use effects to synchronise a component with an external system; use event handlers for things caused by a specific user action.
- An effect has a setup, an optional cleanup and a dependency array; React runs cleanup before each re-run and on unmount.
- List every reactive value the effect reads as a dependency; fix the code instead of silencing the lint rule.
- Use cleanup (an
AbortControlleror anignoreflag) to avoid fetch race conditions; Strict Mode double-runs effects in development to expose missing cleanups. - Many effects are unnecessary: derive values during render, handle actions in handlers, reset with keys.
Practice
- (Beginner) Write a
useEffectthat setsdocument.titleto "(N) Bookshelf" where N is the number of unread books, and verify it updates when N changes.- (Intermediate) Build a clock component that shows the current time and updates every second. Make it work correctly under Strict Mode, stop the interval on unmount, and add a prop that switches between 12- and 24-hour format without restarting the interval.
- (Interview) "A colleague's component fetches data in
useEffectand sometimes shows results for an old search term. What is wrong and how do you fix it?" Hint: responses arriving out of order, what the cleanup function is for, and why a caching library avoids the issue.
Component Lifecycle in Hook Terms
The three phases. Every component instance goes through the same life: it mounts (its first render is committed to the DOM), it updates (it re-renders zero or more times because of new props, state or context), and it unmounts (it is removed from the tree and its state is discarded). Class components (see "Class Components and Legacy Lifecycle Methods") exposed a method for each moment. Function components express the same moments through hooks, but the recommended way of thinking is different.
One update, step by step.
+----------------------------------------------+
trigger | mount / setState / parent render / context |
+----------------------------------------------+
|
RENDER call the component function: hooks return current state,
JSX is produced, children are rendered
|
COMMIT React updates the DOM
|
ref callbacks / ref.current assigned
|
useLayoutEffect: cleanups of the previous run, then setups
|
PAINT browser draws the frame
|
useEffect: cleanups of the previous run, then setups
Within the tree, effects run children first: a child's effects run before its parent's, because a parent can only be "fully mounted" once its children are.
Mapping lifecycle moments to hooks.
| Lifecycle moment | Class component | Function component |
|---|---|---|
| Initialise state | constructor | useState(initial) / useState(() => initial) |
| Render | render() | The function body |
| After first mount | componentDidMount | useEffect(fn, []) |
| After updates of specific values | componentDidUpdate(prevProps) with comparisons | useEffect(fn, [a, b]) |
| Before unmount | componentWillUnmount | Cleanup returned from useEffect |
| Skip re-render if props equal | shouldComponentUpdate / PureComponent | memo(Component) |
| Measure DOM before paint | componentDidMount/componentDidUpdate (synchronous) | useLayoutEffect |
| Catch render errors | componentDidCatch, getDerivedStateFromError | No hook: still needs a class error boundary (14.3) |
Think in synchronisation, not in moments. The table is useful when reading old code, but writing effects as "on mount, do X" leads to bugs. Consider an effect written with only a mount and unmount in mind:
useEffect(() => {
const sub = stockFeed.subscribe(symbol, setPrice); // "on mount: subscribe"
return () => sub.unsubscribe(); // "on unmount: unsubscribe"
}, []); // bug: symbol changes are ignored
If the symbol prop changes, the component keeps showing prices for the old symbol. The correct question is not "when does this run?" but "what does this effect keep in sync, and with which values?" Answer: it keeps a subscription in sync with symbol. That makes [symbol] the dependency list, and React calls cleanup and setup whenever symbol changes. Each effect describes how to start and how to stop synchronising; React decides when.
Observing the lifecycle. A small component that logs each step makes the order visible:
function Lifecycle({ label }) {
console.log(`render ${label}`);
useLayoutEffect(() => {
console.log(`layout effect ${label}`);
return () => console.log(`layout cleanup ${label}`);
}, [label]);
useEffect(() => {
console.log(`effect ${label}`);
return () => console.log(`effect cleanup ${label}`);
}, [label]);
return <p>{label}</p>;
}
// mount "A": render A, layout effect A, effect A
// update to "B": render B, layout cleanup A, layout effect B, effect cleanup A, effect B
// unmount: layout cleanup B, effect cleanup B
// (in Strict Mode dev you will additionally see the extra mount-unmount-mount cycle)
Keeping a component alive while hidden. Unmounting discards state. If you want to hide a tab but keep its state (scroll position, form inputs), you can render it and hide it with CSS, or, since React 19.2, wrap it in <Activity mode="hidden">. React then keeps the state but unmounts its effects while it is hidden, and re-creates them when it becomes visible.
Key Takeaways
- A component mounts, updates any number of times, and unmounts; unmounting discards its state.
- Order of one update: render, DOM commit, refs, layout effects, paint, effects; children's effects run before their parents'.
- Class lifecycle methods map to
useState,useEffectwith dependencies and cleanup,useLayoutEffectandmemo; error boundaries still need classes. - Design effects around what they synchronise and with which values, not around "on mount" and "on unmount".
Practice
- (Beginner) Use the
Lifecyclecomponent above inside a parent that can toggle it and change its label. Record the log order with and without Strict Mode and explain each line.- (Intermediate) Translate a class component that uses
componentDidMount,componentDidUpdate(prevProps)(to refetch whenuserIdchanges) andcomponentWillUnmountinto a function component. Show that the hook version needs fewer branches.- (Interview) "What is the equivalent of
componentDidMountin hooks, and why is that the wrong question?" Hint:useEffect(fn, [])is the literal answer; then explain why dependencies describe synchronisation and what bug appears when props change.
useRef and DOM Access
Why refs. Sometimes a component must remember a value that is not used for rendering: a timer ID, the previous value of a prop, a WebSocket instance. Storing it in state would cause pointless re-renders. Other times a component must reach a real DOM node: to focus an input, measure an element, scroll into view, or hand a node to a non-React library such as a chart or a map. useRef covers both needs. React's documentation calls refs an escape hatch: a way to step outside the declarative model when you must.
A mutable box. useRef(initial) returns an object { current: initial } that persists for the life of the component. You can read and write ref.current freely, and changing it does not trigger a render.
useState | useRef | |
|---|---|---|
| Survives re-renders | Yes | Yes |
| Changing it triggers a render | Yes | No |
| Value in the current render | Snapshot (immutable) | Live (mutable current) |
| Use for | Anything shown on screen | Timer IDs, DOM nodes, instances of external objects |
function Stopwatch() {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef(null); // the ID is never rendered: a ref is enough
function start() {
if (intervalRef.current) return; // already running
const startedAt = Date.now() - elapsed;
intervalRef.current = setInterval(() => setElapsed(Date.now() - startedAt), 100);
}
function stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
useEffect(() => stop, []); // clear the interval if unmounted while running
return (
<>
<p>{(elapsed / 1000).toFixed(1)} s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}
Refs to DOM nodes. Pass a ref object to the ref attribute of a DOM element. After the commit, React sets ref.current to the DOM node; on unmount it sets it back to null.
function SearchBar() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} type="search" aria-label="Search books" />
<button onClick={() => inputRef.current.focus()}>Focus search</button> {/* imperative DOM call */}
</>
);
}
Do not read or write refs during render. During the render the DOM node may not exist yet, and a ref changed during render makes the component impure. Use refs in event handlers and effects. The one exception is lazy initialisation: if (ref.current === null) ref.current = new ExpensiveThing();.
Integrating a non-React library. A ref plus an effect is the standard bridge:
function BookMap({ lat, lng }: { lat: number; lng: number }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapWidget | null>(null);
useEffect(() => {
const map = new MapWidget(containerRef.current!); // library takes over this div
mapRef.current = map;
return () => { map.destroy(); mapRef.current = null; };
}, []);
useEffect(() => {
mapRef.current?.setCenter(lat, lng); // sync props into the imperative API
}, [lat, lng]);
return <div ref={containerRef} style={{ height: 300 }} />; // React renders nothing inside it
}
Passing refs to your own components. Since React 19, ref is an ordinary prop for function components, so a design-system input can expose its inner DOM node:
function TextInput({ label, ref, ...rest }: React.ComponentProps<'input'> & { label: string }) {
return (
<label>
{label}
<input ref={ref} {...rest} /> {/* the parent's ref reaches the DOM input */}
</label>
);
}
Before React 19 the same component had to be wrapped in forwardRef((props, ref) => ...), which you will see in most existing libraries.
Ref callbacks. Instead of an object you can pass a function. React calls it with the node after mount; since React 19, the function may return a cleanup that runs on detach. This is useful for refs to a dynamic number of elements or for observers:
<div
ref={(node) => {
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
observer.observe(node);
return () => observer.disconnect(); // React 19: cleanup on unmount or when the callback changes
}}
/>
Exposing a limited API with useImperativeHandle. If you want a parent to call ref.current.focus() but not to access the whole DOM node, define the methods it may use:
function VideoPlayer({ src, ref }) {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
}), []);
return <video ref={videoRef} src={src} />;
}
Key Takeaways
useRefreturns a persistent mutable{ current }box; changing it does not re-render.- Use refs for values not shown on screen (timer IDs, instances) and to access DOM nodes via the
refattribute. - Read and write refs in handlers and effects, not during render.
- A ref plus an effect is the standard way to integrate non-React libraries.
- In React 19,
refis a normal prop for function components (noforwardRef), and ref callbacks can return a cleanup;useImperativeHandleexposes a restricted API.
Practice
- (Beginner) Build a login form that focuses the email input when it mounts and moves focus to the password field when the user presses Enter in the email field.
- (Intermediate) Write a
usePrevious(value)hook withuseRefanduseEffectand use it to show "up" or "down" next to a changing price. Explain why the ref is updated in an effect and not during render.- (Interview) "When would you use a ref instead of state?" Hint: ask whether the value affects what is rendered; mention timer IDs, DOM access, third-party instances, and the bug of displaying
ref.currenton screen.
useMemo and useCallback
Why memoisation. Every render re-runs the whole component body. Usually that is cheap. Two situations make it a problem: an expensive calculation that runs on every render even though its inputs did not change, and referential identity. Objects and functions created during render are new on every render, even if their contents are the same:
({ a: 1 }) === ({ a: 1 }) // false: two different objects
(() => {}) === (() => {}) // false: two different functions
This matters when a new object or function is passed to a child wrapped in memo, or used as an effect dependency. The child sees "changed" props and re-renders, and the effect re-runs, although nothing really changed. Memoisation means caching a result and returning the cached value while the inputs stay the same.
useMemo caches a value.
function BookStats({ books, year }) {
const stats = useMemo(() => {
// Imagine this scans tens of thousands of books
const ofYear = books.filter((b) => b.year === year);
return { count: ofYear.length, avgPages: average(ofYear.map((b) => b.pages)) };
}, [books, year]); // recomputed only when books or year change (compared with Object.is)
return <p>{stats.count} books, {stats.avgPages} pages on average</p>;
}
useCallback caches a function. useCallback(fn, deps) is exactly useMemo(() => fn, deps): it returns the same function instance until a dependency changes.
const BookRow = memo(function BookRow({ book, onToggle }) { // memo: skip render if props are equal
return (
<li>
<label>
<input type="checkbox" checked={book.read} onChange={() => onToggle(book.id)} />
{book.title}
</label>
</li>
);
});
function ReadingList({ books, setBooks }) {
const [filter, setFilter] = useState('');
// Without useCallback: a new function each render -> every memo'd BookRow re-renders on each keystroke
const handleToggle = useCallback((id) => {
setBooks((prev) => prev.map((b) => (b.id === id ? { ...b, read: !b.read } : b)));
}, [setBooks]); // setters from useState are stable, so this almost never changes
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<ul>
{books.filter((b) => b.title.includes(filter)).map((b) => (
<BookRow key={b.id} book={b} onToggle={handleToggle} />
))}
</ul>
</>
);
}
memo, useMemo and useCallback work together.
| Tool | Caches | Purpose |
|---|---|---|
memo(Component) | The rendered output of a component | Skip re-rendering when all props are Object.is-equal to the previous ones |
useMemo(fn, deps) | The return value of fn | Avoid expensive recalculation; keep object/array props stable |
useCallback(fn, deps) | The function itself | Keep callback props and effect dependencies stable |
useCallback on its own does nothing useful: a stable function only helps if something compares it (a memo child, an effect dependency, another hook's dependency).
When not to memoise. Memoisation has a cost: extra code, dependency arrays to maintain, memory, and a comparison on every render. Wrapping everything "just in case" makes code harder to read for little gain. Reasonable rules:
- Measure first with the React DevTools Profiler (14.5). A calculation under about 1 ms is rarely worth caching.
- Memoise when you pass objects or functions to
memochildren, or when they are dependencies of effects or other hooks. - Prefer structural fixes first: move state down so fewer components re-render, or pass JSX as
children(14.5). useMemois a performance hint, not a guarantee. React may discard the cache, so code must still be correct without it.
The React Compiler. Released as stable in October 2025, the React Compiler analyses components at build time and inserts memoisation automatically, at a finer granularity than you would write by hand. In projects that enable it, most manual useMemo, useCallback and memo calls become unnecessary. You still need to understand them to read existing code and to reason about referential identity.
Key Takeaways
- Objects and functions created during render are new each time; this defeats
memoand re-triggers effects. useMemocaches a computed value anduseCallbackcaches a function until their dependencies change.- They are useful together with
memochildren, effect dependencies, and genuinely expensive computations. - Do not memoise by default: measure, prefer structural fixes, and remember the cache is only a hint.
- The React Compiler can add memoisation automatically.
Practice
- (Beginner) Generate 20,000 fake books and render a component that filters and sorts them by a text query and a sort key. Use
console.timeto measure the computation, then adduseMemoand type into an unrelated input on the same page to see the difference.- (Intermediate) In the
ReadingListexample, use React DevTools "Highlight updates" to show that every row re-renders on each keystroke withoutmemoanduseCallback. Then find a solution that needs neither, by restructuring which component owns thefilterstate.- (Interview) "What is the difference between
useMemoanduseCallback, and why might addinguseCallbackeverywhere make an application slower?" Hint: one is defined in terms of the other; consider the cost of comparisons, closures and maintenance, and when nothing compares the function anyway.
useReducer for Complex State
Why a reducer. As a component grows, state updates scatter across many event handlers. Each handler knows how to change several pieces of state, and related changes can be forgotten. Consider a checkout form with status, error, items and coupon: the "submit", "success", "failure", "add item" and "apply coupon" handlers all touch overlapping state. A reducer moves all the update logic into one pure function. Handlers just describe what happened by dispatching an action, and the reducer decides how the state changes.
An analogy: instead of each employee editing the shared ledger directly, they file a slip saying "sold 2 items". One accountant (the reducer) applies every slip by the same rules.
The pieces.
event handler --dispatch({ type: 'added', book })--> reducer(state, action) --> new state --> re-render
type Book = { id: number; title: string; read: boolean };
type State = {
books: Book[];
status: 'idle' | 'saving' | 'error';
error: string | null;
};
type Action =
| { type: 'added'; book: Book }
| { type: 'toggled'; id: number }
| { type: 'removed'; id: number }
| { type: 'save_started' }
| { type: 'save_failed'; message: string }
| { type: 'save_succeeded' };
// Pure function: no fetch, no mutation, same input -> same output
function shelfReducer(state: State, action: Action): State {
switch (action.type) {
case 'added':
return { ...state, books: [...state.books, action.book] };
case 'toggled':
return {
...state,
books: state.books.map((b) => (b.id === action.id ? { ...b, read: !b.read } : b)),
};
case 'removed':
return { ...state, books: state.books.filter((b) => b.id !== action.id) };
case 'save_started':
return { ...state, status: 'saving', error: null };
case 'save_failed':
return { ...state, status: 'error', error: action.message };
case 'save_succeeded':
return { ...state, status: 'idle' };
default: {
const unreachable: never = action; // TypeScript errors here if an action type is unhandled
return state;
}
}
}
import { useReducer } from 'react';
function Shelf({ initialBooks }: { initialBooks: Book[] }) {
const [state, dispatch] = useReducer(shelfReducer, {
books: initialBooks, status: 'idle', error: null,
});
async function handleSave() {
dispatch({ type: 'save_started' });
try {
await saveShelf(state.books); // side effects stay in the handler
dispatch({ type: 'save_succeeded' });
} catch (e) {
dispatch({ type: 'save_failed', message: (e as Error).message });
}
}
return (
<>
<ul>
{state.books.map((b) => (
<li key={b.id}>
<input type="checkbox" checked={b.read}
onChange={() => dispatch({ type: 'toggled', id: b.id })} />
{b.title}
<button onClick={() => dispatch({ type: 'removed', id: b.id })}>Remove</button>
</li>
))}
</ul>
<button onClick={handleSave} disabled={state.status === 'saving'}>Save</button>
{state.error && <p role="alert">{state.error}</p>}
</>
);
}
Benefits. The reducer is a plain function, so you can unit test it without React (expect(shelfReducer(s, { type: 'removed', id: 1 }).books).toHaveLength(0)). Every possible change is visible in one place. Actions are a readable log of what happened, and you can console.log them while debugging. dispatch is stable across renders, so it can be passed to children or put in context without memoisation.
Rules for reducers. They must be pure, like render. Never mutate state; return a new object (or use Immer's useImmerReducer). Name actions after what happened ('save_failed'), not after the setter you want to call ('set_error'). Keep side effects such as fetch in handlers or effects.
Choosing.
| Criterion | useState | useReducer |
|---|---|---|
| Shape of state | Independent primitive values | Several related values that change together |
| Update logic | Simple, local to one handler | Shared by many handlers or with many cases |
| Readability | Less code for small cases | More upfront code, clearer at scale |
| Testing | Through the component | Reducer testable in isolation |
| Debugging | Inspect state | Log every action and resulting state |
You can mix both in one component. The reducer pattern is also the foundation of Redux (14.4), which applies it to state shared by the whole application.
Key Takeaways
useReducer(reducer, initialState)returns[state, dispatch]; handlers dispatch actions and a pure reducer computes the next state.- Reducers centralise update logic, are testable in isolation and make state transitions explicit.
- Reducers must be pure and immutable; side effects stay in handlers or effects.
- Prefer
useReducerwhen several values change together or many handlers share update logic;useStateremains best for simple, independent values.
Practice
- (Beginner) Rewrite a counter with "increment", "decrement", "reset" and "set to N" buttons using
useReducer, and write three unit tests for the reducer.- (Intermediate) Model a multi-step "add book" wizard (details, cover upload, confirmation) with a reducer whose state includes the current step, the form data per step and a submission status. Make invalid transitions (jumping to confirmation without a title) impossible inside the reducer.
- (Interview) "When would you choose
useReduceroveruseState?" Hint: talk about related state, the number of update sites, testability, and the stability ofdispatchwhen passing it deep into the tree.
useContext and Prop Drilling
The problem: prop drilling. Some data is needed by many components at different depths: the logged-in user, the theme, the language, feature flags. Passing it down as props means every intermediate component must accept and forward it, even when it does not use it:
<App user theme>
<Layout user theme> does not use them, only forwards
<Sidebar user theme> does not use them, only forwards
<Avatar user theme /> the only real consumer
This is called prop drilling. It couples intermediate components to data they do not care about and makes refactoring painful. First try composition (14.1); when data really is needed in many distant places, use context.
Context. Context lets a component provide a value to its whole subtree. Any descendant can read it directly, however deep, like a radio broadcast that every receiver in range can tune into without wires running through each room.
// theme-context.tsx
import { createContext, useContext, useMemo, useState } from 'react';
type Theme = 'light' | 'dark';
type ThemeContextValue = { theme: Theme; toggleTheme: () => void };
// 1. Create the context, with a default used when there is no provider above
const ThemeContext = createContext<ThemeContextValue | null>(null);
// 2. Provide it: a component that owns the state and wraps the tree
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const value = useMemo(
() => ({ theme, toggleTheme: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')) }),
[theme], // a new object only when theme changes
);
return <ThemeContext value={value}>{children}</ThemeContext>; // React 19; older: <ThemeContext.Provider value={value}>
}
// 3. Consume it through a custom hook that also checks for a missing provider
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}
// main.tsx
<ThemeProvider>
<App />
</ThemeProvider>
// Anywhere below, no props needed
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>Switch to {theme === 'light' ? 'dark' : 'light'} mode</button>;
}
How lookup works. useContext(SomeContext) returns the value of the nearest provider above the calling component. Providers can be nested and overridden for part of the tree (for example a dark sidebar inside a light page). Without any provider, the default from createContext is returned. React 19 also lets you read context with use(SomeContext), which, unlike useContext, may be called inside conditions.
Re-render behaviour. When a provider's value changes (compared with Object.is), every component that reads that context re-renders, even if it only uses a part of the value that did not change. This leads to two practical rules:
- Keep the value stable. Passing
value={{ theme, toggleTheme }}creates a new object on every render of the provider and re-renders all consumers. Memoise it as above. - Split contexts by how often they change. Put the rarely changing user identity and the frequently changing shopping cart in separate contexts, and separate state from the functions that update it when that helps.
AuthContext { user, login, logout } changes on login/logout
ThemeContext { theme, toggleTheme } changes rarely
CartContext { items } changes often -> only cart widgets re-render
CartActionsCtx { add, remove } stable (dispatch-based) -> buttons never re-render
When to use context.
| Good fit | Poor fit |
|---|---|
| Theme, locale, authenticated user, feature flags | Data that changes many times per second (pointer position, form keystrokes) |
| Dependencies such as an API client or analytics service | Server data that needs caching and refetching (use a query library, 14.4) |
Shared state of a compound component (Tabs and its Tabs) | Avoiding two levels of props (composition is simpler) |
Context is a transport mechanism, not a state management library. Combined with useReducer it is enough for many applications; larger ones may adopt Redux or a lighter store (14.4).
Key Takeaways
- Prop drilling passes data through components that do not use it; try composition first, then context.
createContextcreates a context, a provider (<Ctx value>in React 19,<Ctx.Provider>before) supplies a value, anduseContext/usereads the nearest one.- Wrap each context in a provider component and a custom hook that throws when the provider is missing.
- All consumers re-render when the value changes: memoise the value and split contexts by update frequency.
- Context transports values; it does not cache server data or replace a state library in large apps.
Practice
- (Beginner) Create a
LocaleContextwith'en'and'it', a provider with a switch button, and auseT(key)hook that reads translations from an object. Use it in two components at different depths.- (Intermediate) Build an
AuthProviderexposinguser,login(email, password)andlogout()against the Chapter 12 login endpoint. Add a counter in each consumer that logs renders, then split state and actions into two contexts and show that a "Log out" button no longer re-renders when the user object changes.- (Interview) "Is React Context a replacement for Redux?" Hint: distinguish dependency injection from state management; discuss re-render granularity, selectors, middleware, DevTools and the size of the application.
Custom Hooks and the Rules of Hooks
Why custom hooks. Several components often need the same stateful logic: tracking whether the browser is online, debouncing a search term, reading and writing localStorage, subscribing to a WebSocket. Before hooks, sharing such logic required higher-order components or render props (14.3), which nested wrappers around components. A custom hook is simply a function whose name starts with use and that calls other hooks. It shares logic, and each component that calls it gets its own independent state.
// useOnlineStatus.ts
import { useEffect, useState } from 'react';
export function useOnlineStatus() {
const [online, setOnline] = useState(() => navigator.onLine);
useEffect(() => {
const update = () => setOnline(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return online;
}
// Two components, two independent subscriptions, one implementation
function SaveButton() {
const online = useOnlineStatus();
return <button disabled={!online}>{online ? 'Save' : 'Offline'}</button>;
}
function StatusBar() {
return <footer>{useOnlineStatus() ? 'Connected' : 'Working offline'}</footer>;
}
More examples that appear in almost every project:
// Debounce: return a value that only updates after `delay` ms without changes
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // a new keystroke cancels the pending update
}, [value, delay]);
return debounced;
}
// Persisted state with the same API as useState
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw === null ? initial : (JSON.parse(raw) as T);
} catch {
return initial; // private mode, quota or corrupted JSON
}
});
useEffect(() => {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore */ }
}, [key, value]);
return [value, setValue] as const; // tuple type, like useState
}
// Usage: search the API only when the user pauses typing
function Search() {
const [query, setQuery] = useLocalStorage('lastQuery', '');
const debouncedQuery = useDebouncedValue(query, 400);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<BookResults query={debouncedQuery} />
</>
);
}
The Rules of Hooks.
- Only call hooks at the top level of a component or custom hook: never inside conditions, loops, nested functions, after an early
return, or intry/catch. - Only call hooks from React functions: function components and custom hooks, not ordinary functions or class components.
Why the rules exist. Hooks have no names. React stores each component's hook data in a list and identifies each hook by its call order:
render 1 render 2 (if a hook is skipped by a condition)
slot 0: useState('Ada') slot 0: useState('Ada') OK
slot 1: useState(0) <--- slot 1: useEffect(...) React hands the effect the counter's slot: corrupted
slot 2: useEffect(...) slot 2: ???
If a hook is called conditionally, the order changes between renders and each hook receives another hook's data. The rules guarantee that the order is identical on every render. Put the condition inside the hook instead:
// Wrong
if (user) {
useEffect(() => track(user.id), [user]);
}
// Right
useEffect(() => {
if (user) track(user.id);
}, [user]);
React 19's use() is the documented exception: it may be called conditionally, because it reads a promise or context rather than storing per-component data.
Enforce them with a linter. eslint-plugin-react-hooks (included in the Vite template) reports rule violations (rules-of-hooks) and missing dependencies (exhaustive-deps). Treat its warnings as errors.
Designing good custom hooks. Name them after their purpose (useOnlineStatus), not their implementation (useEventListenerEffect). Return what the caller needs: a single value, a tuple like useState, or an object for many fields. Avoid lifecycle-style hooks such as useMount(fn), which hide dependencies from the linter. And remember that a function that calls no hooks should not start with use; it is just a function.
Key Takeaways
- A custom hook is a
use-prefixed function that calls other hooks; it shares logic, not state. - Common examples: online status, debounce, local storage, media queries, data subscriptions.
- Call hooks only at the top level of components and custom hooks; React identifies hooks by call order.
- Put conditions inside hooks, never around them;
use()is the only hook that may be called conditionally. - Use
eslint-plugin-react-hooksto enforce the rules and dependency lists.
Practice
- (Beginner) Write
useWindowWidth()that returns the currentwindow.innerWidthand updates on resize, and use it to switch between a grid and a list layout.- (Intermediate) Write
useFetch<T>(url)returning{ data, error, status }that aborts the previous request when the URL changes. Use it in two components and show that each has its own state. Then list three features a production data-fetching library adds on top of it.- (Interview) "Why can't hooks be called inside an
ifstatement?" Hint: explain how React associates hook state with call order on each render, and what the linter does about it.
Class Components and Legacy Lifecycle Methods
Why study classes. From 2015 until hooks arrived in React 16.8 (February 2019), class components were the only way to have state and lifecycle methods. Large existing codebases and many tutorials still use them, and error boundaries (14.3) can still only be written as classes. You need to read them fluently, even if you write new code with functions.
Anatomy of a class component.
import { Component } from 'react';
class BookTimer extends Component {
// State is a single object
state = { seconds: 0, running: false };
// Class field arrow functions keep `this` bound to the instance
toggle = () => {
this.setState((prev) => ({ running: !prev.running })); // setState MERGES into this.state
};
componentDidMount() {
this.intervalId = setInterval(() => {
if (this.state.running) this.setState((s) => ({ seconds: s.seconds + 1 }));
}, 1000);
}
componentDidUpdate(prevProps) {
if (prevProps.bookId !== this.props.bookId) { // manual comparison of what changed
this.setState({ seconds: 0, running: false });
}
}
componentWillUnmount() {
clearInterval(this.intervalId);
}
render() {
const { title } = this.props; // props are on this.props
return (
<div>
<p>Reading "{title}" for {this.state.seconds} s</p>
<button onClick={this.toggle}>{this.state.running ? 'Pause' : 'Start'}</button>
</div>
);
}
}
Differences from function components:
| Aspect | Class component | Function component |
|---|---|---|
| Props and state | this.props, this.state (one object) | Function parameter; one useState per value |
| Updating state | this.setState(partial) shallow-merges | Setter replaces the value |
| Event handlers | Must bind this (constructor bind or class field arrows) | Closures, no this |
| Side effects | Split across componentDidMount, componentDidUpdate, componentWillUnmount | One useEffect per concern, setup and cleanup together |
| Logic reuse | HOCs and render props | Custom hooks |
| Reading values in async callbacks | this.props is mutable: may read newer props than the render that started the work | Closures capture the render's snapshot |
The this binding problem. Methods passed as callbacks lose their this (Chapter 4). Older code binds them in the constructor:
constructor(props) {
super(props); // required before using `this`
this.state = { open: false };
this.handleClick = this.handleClick.bind(this);
}
handleClick() { this.setState({ open: true }); }
The full lifecycle.
MOUNTING constructor -> static getDerivedStateFromProps -> render -> componentDidMount
UPDATING static getDerivedStateFromProps -> shouldComponentUpdate -> render
-> getSnapshotBeforeUpdate -> componentDidUpdate(prevProps, prevState, snapshot)
UNMOUNTING componentWillUnmount
ERRORS static getDerivedStateFromError -> componentDidCatch
| Method | Purpose |
|---|---|
shouldComponentUpdate(nextProps, nextState) | Return false to skip rendering; PureComponent implements it with shallow comparison |
static getDerivedStateFromProps(props, state) | Rarely needed: update state from props before every render |
getSnapshotBeforeUpdate(prevProps, prevState) | Read DOM information (such as scroll position) just before it changes; the result goes to componentDidUpdate |
static getDerivedStateFromError(error) | Return state to render a fallback after a descendant throws |
componentDidCatch(error, info) | Log the error (side effects allowed) |
Deprecated methods. componentWillMount, componentWillReceiveProps and componentWillUpdate ran during the render phase, where async rendering may run them several times or discard their work. They were renamed with an UNSAFE_ prefix in React 16.3, and Strict Mode warns about them. Replace them with componentDidMount, derived values or componentDidUpdate.
Why hooks replaced classes. Related logic was scattered across lifecycle methods (a subscription set up in one method and torn down in another), while unrelated logic lived together in the same method. Reusing stateful logic created wrapper hell. this was confusing. And classes were harder for tools to optimise. Hooks group code by concern rather than by lifecycle moment. Classes are still supported and not deprecated, but all new React features (concurrent rendering APIs, use, actions) target function components.
Migrating a class to a function.
function BookTimer({ bookId, title }) {
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(false);
useEffect(() => { // literal translation; simpler: render <BookTimer key={bookId} />
setSeconds(0);
setRunning(false);
}, [bookId]);
useEffect(() => {
if (!running) return; // only tick while running
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, [running]);
return (
<div>
<p>Reading "{title}" for {seconds} s</p>
<button onClick={() => setRunning((r) => !r)}>{running ? 'Pause' : 'Start'}</button>
</div>
);
}
Key Takeaways
- Class components keep state in
this.state, update it with mergingsetState, and needthisbinding for handlers. - Lifecycle methods split by moment: mount (
componentDidMount), update (componentDidUpdate,shouldComponentUpdate), unmount (componentWillUnmount), errors (getDerivedStateFromError,componentDidCatch). UNSAFE_methods are deprecated because they run in the render phase.- Hooks replaced classes because they group logic by concern and make it reusable; classes remain necessary for error boundaries.
- Migrating means converting state fields to
useState, lifecycle pairs to effects with cleanup, andprevPropscomparisons to dependency arrays.
Practice
- (Beginner) Write a class component
Togglewith a button that shows "ON"/"OFF", once with constructor binding and once with a class field arrow function.- (Intermediate) Convert a class component that subscribes to window resize in
componentDidMount, unsubscribes incomponentWillUnmount, and refetches data incomponentDidUpdatewhenprops.userIdchanges, into a function component with two effects and a custom hook.- (Interview) "In a class component, a handler shows a message using
this.props.userthree seconds after a click. What can go wrong that cannot happen in a function component?" Hint:thisis mutable and always points to the latest props; closures capture the props of the render in which the handler was created.
<a id="143-patterns-and-application-structure"></a>
14.3 Patterns and Application Structure
With components and hooks in hand, the next question is how to organise them. This subchapter covers forms, the direction in which data flows between components, the older reuse patterns (higher-order components and render props) that hooks largely replaced, portals and error boundaries, the main approaches to styling, and the component libraries and design systems on which most production interfaces are built.
Controlled and Uncontrolled Forms
Two places a value can live. A text field has a value. In plain HTML the DOM keeps it, and you read it when you need it. In React you can leave it there or move it into state. That choice defines the two kinds of form inputs:
- A controlled input gets its value from React state (
value) and reports every change (onChange). React state is the single source of truth, and the DOM displays it. - An uncontrolled input keeps its value in the DOM. You may give it a starting value (
defaultValue) and read it later, through a ref or from the submitted form data.
An analogy: a controlled input is a puppet that only moves when React pulls the strings; an uncontrolled input is an independent actor you check on at the end of the scene.
Controlled inputs.
function NewBookForm({ onCreate }: { onCreate: (b: { title: string; author: string; year: number }) => void }) {
const [title, setTitle] = useState('');
const [author, setAuthor] = useState('');
const [year, setYear] = useState(''); // keep the raw string; convert on submit
const titleError = title.trim().length === 0 ? 'Title is required' : null; // validated on every keystroke
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); // stop the browser's full-page submit
if (titleError) return;
onCreate({ title: title.trim(), author, year: Number(year) });
setTitle(''); setAuthor(''); setYear(''); // resetting is just setting state
}
return (
<form onSubmit={handleSubmit} noValidate>
<label>
Title
<input value={title} onChange={(e) => setTitle(e.target.value)} aria-invalid={!!titleError} />
</label>
{titleError && <small role="alert">{titleError}</small>}
<label>
Author
<input value={author} onChange={(e) => setAuthor(e.target.value)} />
</label>
<label>
Year
<input inputMode="numeric" value={year}
onChange={(e) => setYear(e.target.value.replace(/\D/g, ''))} /> {/* reject non-digits as typed */}
</label>
<button type="submit" disabled={!!titleError}>Add book</button>
</form>
);
}
Because the value passes through your code on each keystroke, you can validate instantly, transform input (upper-case, strip characters), enable or disable buttons, and keep several fields in sync. A value without an onChange makes the field read-only, and React warns about it.
Other elements follow the same pattern with small differences:
| Element | Controlled prop | Read the change from |
|---|---|---|
<input type="text">, <textarea> | value | e.target.value |
<input type="checkbox"> | checked | e.target.checked |
<input type="radio"> | checked={choice === 'x'} | e.target.value |
<select> | value on the <select> (not selected on options) | e.target.value |
<select multiple> | value={['a', 'b']} | Array.from(e.target.selectedOptions, (o) => o.value) |
<input type="file"> | Always uncontrolled (value is read-only) | e.target.files |
Uncontrolled inputs. For a simple form that you only read on submit, the DOM can keep the values. FormData collects them by name:
function SearchForm({ onSearch }) {
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget); // reads every named field from the DOM
onSearch({ q: data.get('q'), genre: data.get('genre') });
}
return (
<form onSubmit={handleSubmit}>
<input name="q" defaultValue="" /> {/* defaultValue, not value */}
<select name="genre" defaultValue="all">
<option value="all">All</option>
<option value="sf">Science fiction</option>
</select>
<button>Search</button>
</form>
);
}
Form actions in React 19. React 19 lets you pass a function to a form's action prop. React calls it with the FormData on submit, tracks its pending state, and resets uncontrolled fields after success. useActionState stores the result of the last submission, and useFormStatus lets a child button know that its form is submitting:
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
type Result = { error?: string; ok?: boolean };
async function createBook(_prev: Result, formData: FormData): Promise<Result> {
const res = await fetch('/api/v1/books', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(formData)),
});
if (!res.ok) return { error: (await res.json()).title ?? 'Could not save' }; // problem+json title
return { ok: true };
}
function SubmitButton() {
const { pending } = useFormStatus(); // reads the status of the enclosing <form>
return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
export function QuickAddBook() {
const [state, formAction] = useActionState(createBook, {});
return (
<form action={formAction}>
<input name="title" required />
<input name="author" required />
<SubmitButton />
{state.error && <p role="alert">{state.error}</p>}
{state.ok && <p>Saved.</p>}
</form>
);
}
Form libraries. Large forms (dozens of fields, nested arrays, async validation) are tedious with one useState per field. React Hook Form registers mostly uncontrolled inputs and re-renders only what changes. It integrates with schema validators such as Zod, so the same schema can validate on the client and on the server (see "Request Validation and Sanitization" in 12.3):
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const bookSchema = z.object({
title: z.string().trim().min(1, 'Title is required'),
author: z.string().trim().min(1),
year: z.coerce.number().int().min(1450).max(new Date().getFullYear()), // coerce: input values are strings
});
type BookInput = z.infer<typeof bookSchema>;
function BookForm({ onSubmit }: { onSubmit: (b: BookInput) => Promise<void> }) {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<BookInput>({ resolver: zodResolver(bookSchema) });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('title')} /> {/* register wires name, ref, onChange, onBlur */}
{errors.title && <small>{errors.title.message}</small>}
<input {...register('author')} />
<input {...register('year')} inputMode="numeric" />
{errors.year && <small>{errors.year.message}</small>}
<button disabled={isSubmitting}>Save</button>
</form>
);
}
Choosing.
| Controlled | Uncontrolled | |
|---|---|---|
| Source of truth | React state | The DOM |
| Initial value | value | defaultValue / defaultChecked |
| Instant validation and formatting | Easy | Harder (read via ref or on blur) |
| Re-renders per keystroke | One per change | None |
| Reset | Set state | form.reset() or remount with a new key |
| Typical use | Interactive forms, dependent fields, search-as-you-type | Simple submit-only forms, file inputs, form actions, form libraries |
Never switch an input between controlled and uncontrolled during its life (for example from value={undefined} to value="x"); React warns because it cannot tell who owns the value. Initialise controlled state with '', not undefined.
Whatever the approach, client validation is a convenience for the user. The server must validate again, because any request can be sent without your form.
Key Takeaways
- Controlled inputs store their value in React state (
value+onChange); uncontrolled inputs keep it in the DOM (defaultValue, refs,FormData). - Controlled inputs enable instant validation and formatting; uncontrolled inputs avoid re-renders and suit submit-only forms.
- React 19 form actions (
action={fn},useActionState,useFormStatus) handle submission and pending state with less code. - Libraries such as React Hook Form with Zod scale to large forms and can share validation schemas with the server.
- Never switch an input between controlled and uncontrolled; always validate again on the server.
Practice
- (Beginner) Build a controlled sign-up form with email, password and "confirm password" fields that shows a mismatch error as the user types and disables the submit button until the form is valid.
- (Intermediate) Build the "add book" form for the Chapter 12 API three ways: controlled with
useState, uncontrolled with a React 19 form action, and with React Hook Form and Zod. Show the API's422validation errors next to the right field in each version and compare the amount of code.- (Interview) "What is the difference between a controlled and an uncontrolled component, and when would you choose each?" Hint: talk about the source of truth, re-renders per keystroke, validation timing, file inputs, and what form libraries do internally.
Lifting State Up and Unidirectional Data Flow
The problem. Two sibling components need the same data. A GenreFilter lets the user choose a genre, and a BookList must show only books of that genre. If the filter keeps the selected genre in its own state, the list cannot see it, because siblings cannot read each other's state and props flow only downward.
The solution: lift state up. Move the state to the closest common ancestor. The ancestor passes the value down to whoever displays it and a callback down to whoever changes it:
BookshelfPage
state: genre = 'sf'
/ \
value + onChange value
| |
GenreFilter BookList
(displays, reports) (displays only)
function BookshelfPage({ books }) {
const [genre, setGenre] = useState('all'); // lifted: one source of truth
const visible = genre === 'all' ? books : books.filter((b) => b.genre === genre);
return (
<>
<GenreFilter value={genre} onChange={setGenre} />
<BookList books={visible} />
<p>{visible.length} books shown</p> {/* a third consumer is free */}
</>
);
}
function GenreFilter({ value, onChange }) { // now fully controlled by its parent
return (
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="all">All genres</option>
<option value="sf">Science fiction</option>
<option value="fantasy">Fantasy</option>
</select>
);
}
GenreFilter has become a controlled component in the broader sense: its important value is driven by props, not by its own state. That makes it more reusable, because the parent decides where the value lives.
Unidirectional data flow. React applications follow one rule: data flows down, events flow up.
state (owned by one component)
| props (data down)
v
child components render it
| callback calls (events up)
v
owner updates its state ---> re-render with new props
Every piece of state has exactly one owner, and only the owner changes it. When the screen shows a wrong value, you trace it upward through props to the single component that owns the state and inspect the handlers that change it. Compare this with two-way data binding (Angular's [(ngModel)], Vue's v-model), where a field and a model update each other automatically. Two-way binding is convenient for forms. But when many bindings form a web, a change can trigger chains of updates whose origin is hard to find. React accepts some extra wiring (value + onChange) in exchange for traceability. (Both Angular and Vue also use one-way flow between components; their two-way binding is syntax sugar over a value plus an event.)
Where should state live? A practical procedure:
- List every component that reads the state.
- Find their closest common ancestor.
- Put the state there, or in a component above it if that makes more sense.
- If no reasonable component exists, create a wrapper component just to own it.
Then keep state as low as possible. Lifting state too high makes large parts of the tree re-render on every change. Colocating it with its only consumer is better for performance and readability (14.5). When state is needed by distant parts of the app, move to context or a store (14.4).
The opposite move: pushing state down. If after a refactor only one child reads a piece of state, move it back into that child. State placement is not a one-time decision; it follows the needs of the UI.
Key Takeaways
- Siblings cannot share state directly; lift it to their closest common ancestor and pass value and callback down.
- Data flows down through props and events flow up through callbacks; every piece of state has one owner.
- One-way flow makes changes traceable; two-way binding trades this for convenience in forms.
- Put state at the lowest common ancestor of its readers, and move it down again when fewer components need it.
Practice
- (Beginner) Build two inputs, Celsius and Fahrenheit, that always show the same temperature. Decide which single value to store, where to store it, and derive the other.
- (Intermediate) Build a bookshelf page with a search box, a genre filter, a sort selector, a list and a "N results" counter as separate components. Draw the component tree, mark where each piece of state lives, and justify each choice.
- (Interview) "What is unidirectional data flow, and how is it different from two-way binding?" Hint: describe ownership, how a bug is traced, and how frameworks with two-way binding implement it under the hood.
Higher-Order Components and Render Props
Why these patterns existed. Before hooks (2019), class components had no way to share stateful logic: you could not call a function that "adds a subscription to this component". The community developed two patterns based on composition. Hooks have replaced them for most purposes, but you will meet both in existing code and in some libraries.
Higher-order components (HOCs). A higher-order component is a function that takes a component and returns a new component that wraps it with extra props or behaviour. It applies the idea of higher-order functions (Chapter 3) to components. The naming convention is withSomething:
// A HOC that injects the current window width as a prop
function withWindowWidth(WrappedComponent) {
function WithWindowWidth(props) {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return <WrappedComponent {...props} windowWidth={width} />; // pass through + inject
}
WithWindowWidth.displayName = // readable name in React DevTools
`withWindowWidth(${WrappedComponent.displayName || WrappedComponent.name})`;
return WithWindowWidth;
}
function Gallery({ windowWidth, books }) {
const columns = windowWidth > 900 ? 4 : 2;
return <Grid columns={columns} items={books} />;
}
export default withWindowWidth(Gallery);
Typical HOCs guard routes (withAuth), inject data (connect from older react-redux), add logging or error handling. Two HOCs remain common in modern React: memo (returns a component that skips renders) and lazy (14.5), although they are not usually described that way.
Render props. A render prop is a prop whose value is a function that returns JSX. The component owns the logic and calls the function with its data, and the caller decides what to render:
function WindowWidth({ children }) { // `children` used as the render function
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return children(width); // the caller's function decides the output
}
<WindowWidth>
{(width) => <Grid columns={width > 900 ? 4 : 2} items={books} />}
</WindowWidth>
Render props are still useful when a component must let callers customise the rendering of parts of its output. Example: a virtualised list's renderItem, a combobox's option renderer, or a data table's cell renderer.
The same logic as a hook.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
function Gallery({ books }) {
const width = useWindowWidth(); // no wrapper, no extra tree level, explicit source
return <Grid columns={width > 900 ? 4 : 2} items={books} />;
}
Problems that hooks solved. Stacking several HOCs or render props creates wrapper hell: deeply nested trees in DevTools and pyramids of callbacks in JSX.
export default withRouter(withTheme(withAuth(withWindowWidth(Gallery))));
// Which HOC supplied `user`? Two HOCs both injecting `data` silently overwrite each other.
| HOC | Render prop | Custom hook | |
|---|---|---|---|
| Shape | withX(Component) returns a component | <X>{(data) => ...}</X> | const data = useX() |
| Extra components in the tree | One per HOC | One per render prop | None |
| Where data comes from | Implicit (injected props) | Explicit (function argument) | Explicit (return value) |
| Prop name collisions | Possible | No | No |
| Static typing | Awkward generics | Good | Good |
| Still a good choice for | Wrapping whole components (memo, auth guards in some routers) | Customising rendered parts (renderItem) | Sharing stateful logic (default) |
Key Takeaways
- A higher-order component is a function
withX(Component)that returns a wrapped component with extra props or behaviour. - A render prop is a function prop that a component calls to let the caller decide what to render.
- Both were invented to share stateful logic among class components; custom hooks now do this without extra tree levels or name collisions.
- HOCs still appear as
memo,lazyand some library APIs; render props remain useful for customising parts of a component's output.
Practice
- (Beginner) Write a
withLoading(Component)HOC that renders "Loading..." when aloadingprop is true and the wrapped component otherwise. Set a properdisplayName.- (Intermediate) Implement the same mouse-position tracker three ways (HOC, render prop and custom hook) and use each in a component that draws a dot at the pointer. Compare the component tree in React DevTools and the TypeScript types required.
- (Interview) "Why did hooks largely replace HOCs and render props?" Hint: mention wrapper hell, implicit prop injection and collisions, typing difficulties, and the cases where render props are still the right tool.
Portals and Error Boundaries
This topic covers two features that deal with situations the normal component tree handles badly: content that must escape its parent's box in the DOM, and errors that would otherwise unmount the whole application.
The problem portals solve. A modal dialog, tooltip or dropdown is often logically part of a deep component (a "Delete" button inside a book card), but visually it must float above everything. If it is rendered inside the card, CSS gets in the way. An ancestor with overflow: hidden clips it, and an ancestor with transform or its own stacking context traps its z-index.
Portals. createPortal(children, domNode) renders children into a different DOM node, usually one directly under <body>, while keeping them in the same place in the React tree:
import { createPortal } from 'react-dom';
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); // keyboard users can close it
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
return createPortal(
<div className="backdrop" onClick={onClose}>
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" className="modal"
onClick={(e) => e.stopPropagation()}> {/* clicks inside do not close it */}
<h2 id="modal-title">{title}</h2>
{children}
</div>
</div>,
document.body, // DOM destination: outside every clipping ancestor
);
}
function BookCard({ book, onDelete }) {
const [confirming, setConfirming] = useState(false);
return (
<article className="book-card"> {/* may have overflow: hidden, no problem */}
<h3>{book.title}</h3>
<button onClick={() => setConfirming(true)}>Delete</button>
{confirming && (
<Modal title="Delete book?" onClose={() => setConfirming(false)}>
<button onClick={() => onDelete(book.id)}>Delete</button>
</Modal>
)}
</article>
);
}
React tree (logical) DOM tree (physical)
<BookCard> <body>
<article> <div id="root"> ... <article class="book-card"> ... </article>
<Modal> ---- portal ----> <div class="backdrop"> <div role="dialog"> ... </div></div>
Because the portal stays in the React tree, it still receives context from its ancestors, and React events bubble through the React tree, not the DOM tree. A click inside the modal reaches onClick handlers on BookCard's ancestors even though the DOM nodes are elsewhere. Keep this in mind when a parent handles clicks.
Portals do not make a dialog accessible by themselves. Move focus into it when it opens, trap Tab inside it, restore focus on close, and label it. The native <dialog> element with showModal() renders in the browser's top layer (above all z-index), adds a backdrop, makes the rest of the page inert and handles Escape. It is a good alternative to a hand-made portal modal. Component libraries (see "Component Libraries and Design Systems") provide tested implementations.
The problem error boundaries solve. If a component throws during rendering (for example book.author.name when author is null), React cannot know what the UI should look like. Since React 16, an uncaught render error unmounts the entire application, leaving a blank page. This is safer than showing a corrupted UI, but it means one broken widget takes the whole app down.
Error boundaries. An error boundary is a component that catches errors thrown while rendering its descendants, shows a fallback UI instead, and lets the rest of the application keep working. Like a circuit breaker in a house, it cuts power to one room instead of the whole building. Error boundaries must be class components, because there is no hook equivalent of the two methods they use:
import { Component, type ErrorInfo, type ReactNode } from 'react';
type Props = { fallback: ReactNode; children: ReactNode };
type State = { hasError: boolean };
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true }; // render phase: switch to the fallback
}
componentDidCatch(error: Error, info: ErrorInfo) {
reportError(error, info.componentStack); // commit phase: log to Sentry or similar
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
<Layout>
<ErrorBoundary fallback={<p>Recommendations are unavailable.</p>}>
<Recommendations /> {/* if it crashes, only this box shows the fallback */}
</ErrorBoundary>
<ErrorBoundary fallback={<p>Could not show the book list.</p>}>
<BookList />
</ErrorBoundary>
</Layout>
In practice most teams use the small react-error-boundary library, which provides an <ErrorBoundary> with FallbackComponent, a resetErrorBoundary function for "Try again" buttons, resetKeys and a useErrorBoundary hook to trigger the boundary from async code.
What error boundaries do not catch.
| Not caught | Why | What to do instead |
|---|---|---|
| Errors in event handlers | They do not happen during rendering | try/catch in the handler and set error state |
Async code (setTimeout, promise callbacks) | Runs outside React's render | Catch and store in state; or rethrow into render (useErrorBoundary) |
| Server-side rendering errors | Handled by the server framework | Framework error pages (error.tsx in Next.js) |
| Errors in the boundary itself | It cannot catch its own failure | A boundary higher up |
Where to place boundaries. One at the root to show a friendly "Something went wrong" page instead of a blank screen. One per route, so other pages still work. And one around independent widgets (sidebar, comments, third-party embeds). React 19 also accepts onCaughtError and onUncaughtError options on createRoot for central error reporting.
Key Takeaways
createPortalrenders children into another DOM node (oftendocument.body) while keeping them in the same React tree, which avoidsoverflowandz-indexclipping.- Portal content still receives context, and its events bubble through React ancestors; accessibility (focus, labelling) is still your job, and
<dialog>is a strong native alternative. - An uncaught render error unmounts the whole app; error boundaries contain the failure and show a fallback.
- Error boundaries are class components using
getDerivedStateFromErrorandcomponentDidCatch; react-error-boundary provides a ready-made version. - They do not catch errors in event handlers, async code, SSR or themselves; place them at the root, per route and around independent widgets.
Practice
- (Beginner) Build a
Tooltipthat renders through a portal intodocument.bodyand positions itself under its trigger usinggetBoundingClientRect. Put the trigger in a container withoverflow: hiddento prove the tooltip is not clipped.- (Intermediate) Wrap three dashboard widgets in separate error boundaries, make one throw on a button click by setting state that causes a render error, and add a "Try again" button that resets only that boundary. Then make an event handler throw and explain why the boundary does not catch it.
- (Interview) "Why are error boundaries still class components, and what errors do they miss?" Hint: name the two lifecycle methods involved, the render/commit split, and handle the uncaught cases (handlers, async, SSR) explicitly.
Styling with CSS Modules and CSS-in-JS
Why styling is a problem in component apps. CSS was designed for documents: every selector is global. In an application with hundreds of components written by many people, a rule like .title { font-size: 2rem } written for one card silently affects every other .title. Nobody dares delete old CSS because they cannot know what depends on it. Component-based styling solutions all try to give CSS the same properties as components: local scope, colocation with the component, and dead-code elimination when a component is deleted.
Plain CSS and inline styles. You can import a global stylesheet (import './App.css') and use naming conventions such as BEM (.book-card__title--highlighted) to avoid collisions. The style prop is fine for truly dynamic values (a progress bar's width), but it cannot express hover states, media queries or pseudo-elements, and it bypasses the browser's style caching.
<div className="progress"><div className="progress-fill" style={{ width: `${percent}%` }} /></div>
CSS Modules. A file named *.module.css is processed by the build tool (Vite, webpack and Next.js support it out of the box). Every class name is renamed to a unique one, and importing the file gives you an object that maps your names to the generated ones:
/* BookCard.module.css */
.card { border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
.title { font-size: 1.25rem; margin: 0; }
.card:hover .title { text-decoration: underline; } /* normal CSS: hover, nesting, media queries */
.highlighted { border-color: var(--accent); }
import styles from './BookCard.module.css';
import clsx from 'clsx'; // tiny helper to join class names conditionally
export function BookCard({ book, highlighted }) {
return (
<article className={clsx(styles.card, highlighted && styles.highlighted)}>
<h3 className={styles.title}>{book.title}</h3> {/* rendered as class="_title_x7k2a_5" */}
</article>
);
}
CSS Modules have no runtime cost (the output is ordinary CSS files), work with server rendering, and keep you writing standard CSS. Combined with CSS custom properties (var(--accent)) for theming, they are a solid default.
CSS-in-JS. Libraries such as styled-components and Emotion let you write CSS inside JavaScript, usually with tagged template literals, and create components that carry their styles. Styles can depend on props:
import styled from 'styled-components';
const Card = styled.article`
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 8px;
padding: 1rem;
border-color: ${({ $highlighted, theme }) => ($highlighted ? theme.colors.accent : theme.colors.border)};
`; /* $-prefixed "transient" props are not forwarded to the DOM */
<Card $highlighted={isFavourite}>...</Card>
These runtime CSS-in-JS libraries generate and inject <style> rules in the browser while rendering. That makes them very flexible, but it has costs: extra JavaScript, style computation on every render, and complications with server rendering, streaming and React Server Components (14.5). Since about 2023 the ecosystem has moved toward zero-runtime CSS-in-JS (vanilla-extract, Linaria, Panda CSS, StyleX), which keeps the authoring style but extracts static CSS at build time.
Utility-first CSS. Tailwind CSS takes a different approach: instead of writing CSS, you compose small single-purpose classes directly in the markup. The build step generates only the classes you use.
<article className={`rounded-lg border p-4 ${highlighted ? 'border-indigo-500' : 'border-gray-200'}`}>
<h3 className="m-0 text-xl hover:underline">{book.title}</h3>
</article>
Tailwind removes naming and global-scope problems and keeps the CSS bundle small. It enforces a consistent design scale (spacing, colours) but makes markup verbose; component extraction keeps it manageable.
Comparison.
| Approach | Scope | Runtime cost | Dynamic styles | SSR / Server Components | Typical tools |
|---|---|---|---|---|---|
| Global CSS + BEM | Global (by convention) | None | Classes, CSS variables | Yes | Plain CSS, Sass |
Inline style | Element | Small | Fully dynamic values | Yes | Built in |
| CSS Modules | Local (generated names) | None | Class toggling, CSS variables | Yes | Vite, webpack, Next.js |
| Runtime CSS-in-JS | Local | Yes (style generation in browser) | Props-based, very flexible | Extra setup; not in Server Components | styled-components, Emotion |
| Zero-runtime CSS-in-JS | Local | None | Variants, CSS variables | Yes | vanilla-extract, Panda, StyleX |
| Utility-first | Utility classes | None | Class toggling | Yes | Tailwind CSS |
Practical advice. For new projects, CSS Modules or Tailwind are the common defaults, with CSS custom properties for theming and dark mode. Pass dynamic numeric values through CSS variables (style={{ '--progress': '40%' }}) so the rules stay in CSS. Whatever you choose, be consistent across the codebase.
Key Takeaways
- Global CSS scales badly in component apps; modern approaches aim for local scope, colocation and safe deletion.
- CSS Modules rename classes per file at build time, cost nothing at runtime and keep standard CSS.
- Runtime CSS-in-JS (styled-components, Emotion) is flexible but adds runtime cost and complicates SSR and Server Components; zero-runtime variants avoid this.
- Tailwind composes utility classes in markup and ships only the classes used.
- Use the
styleprop or CSS variables for truly dynamic values; choose one approach and use it consistently.
Practice
- (Beginner) Style a
BookCardwith a CSS Module, add ahighlightedvariant withclsx, and inspect the generated class names in DevTools.- (Intermediate) Implement light and dark themes with CSS custom properties defined on
:rootand[data-theme="dark"], toggled by theThemeProviderfrom 14.2. Then reimplement the same card with Tailwind and compare the markup and CSS bundle size afternpm run build.- (Interview) "What are the trade-offs of runtime CSS-in-JS, and why has the ecosystem moved away from it?" Hint: consider style injection during render, bundle size, server rendering and streaming, React Server Components, and what zero-runtime libraries extract at build time.
Component Libraries and Design Systems
Why not build every component yourself. A production-quality select box, date picker or modal is much harder than it looks. It must support keyboard navigation, focus management and screen readers (WAI-ARIA patterns), right-to-left languages, touch, positioning near viewport edges, and theming. Rebuilding these per project wastes time and usually produces inaccessible widgets. Teams therefore either adopt a component library or build an internal design system, often on top of one.
Design system vs component library.
| Term | What it is |
|---|---|
| Design tokens | Named design decisions: colours, spacing scale, typography, radii, shadows, motion (--color-accent, space-4) |
| Component library | Reusable, tested coded components (Button, Dialog, Table) |
| Design system | Tokens + components + guidelines (when to use what, tone of voice, accessibility rules) + design files + documentation, maintained as a product |
Examples of public design systems: Google's Material Design, IBM's Carbon, Shopify's Polaris, GitHub's Primer and Microsoft's Fluent. A design system is like a city's building code plus a catalogue of prefabricated parts: individual teams build different houses, but doors, stairs and signs work the same everywhere.
Kinds of React component libraries.
| Kind | Examples | You get | Trade-off |
|---|---|---|---|
| Fully styled | MUI (Material UI), Ant Design, Chakra UI, Mantine, Fluent UI | Complete components with a visual style and theming API | Fast to start; harder to match a custom brand; larger bundles |
| Headless (unstyled) | Radix UI Primitives, React Aria, Headless UI, Base UI, Ark UI | Behaviour and accessibility only; you supply the styles | Full visual control; more styling work |
| Copy-in ("open code") | shadcn/ui (Radix or Base UI + Tailwind) | Component source code copied into your repo | You own and modify the code; you also maintain it |
Using a headless primitive. A headless library provides correct behaviour (focus trap, Escape key, ARIA attributes, portal), and you add classes:
import * as Dialog from '@radix-ui/react-dialog';
import styles from './ConfirmDialog.module.css';
export function ConfirmDialog({ trigger, title, onConfirm }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger> {/* asChild: reuse the caller's button */}
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content className={styles.content}> {/* focus trapped, Escape closes */}
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Close asChild><button>Cancel</button></Dialog.Close>
<Dialog.Close asChild><button onClick={onConfirm}>Confirm</button></Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Note the compound component API (Dialog.Root, Dialog.Trigger, Dialog.Content), introduced in 14.1: the parts share state through context, and you arrange them freely.
Building your own design system components. Good shared components:
- Wrap native elements and forward their props and
ref(14.2), so consumers can addaria-*,typeand event handlers. - Expose a small set of variants (
variant="primary" | "secondary" | "danger",size="sm" | "md") instead of arbitrary style props, so that the design stays consistent. - Read colours and spacing from tokens (CSS variables), so theming and dark mode need no code changes.
- Are accessible by default: an
IconButtonrequires anaria-labelprop in its TypeScript type.
type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md';
};
export function Button({ variant = 'primary', size = 'md', className, ...rest }: ButtonProps) {
return <button className={clsx(styles.button, styles[variant], styles[size], className)} {...rest} />;
}
Documenting with Storybook. Storybook is a workshop where each component is rendered in isolation in all its states ("stories"). Designers review it, developers find existing components instead of rewriting them, and stories double as visual regression and accessibility tests.
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
const meta = { component: Button, args: { children: 'Save' } } satisfies Meta<typeof Button>;
export default meta;
export const Primary: StoryObj<typeof meta> = {};
export const Danger: StoryObj<typeof meta> = { args: { variant: 'danger', children: 'Delete' } };
export const Disabled: StoryObj<typeof meta> = { args: { disabled: true } };
Choosing a library. Ask: Do we have a custom brand that must be matched exactly (favour headless or copy-in)? Do we need complex data components such as data grids and date pickers soon (fully styled libraries such as MUI or Ant Design ship them)? Does it work with our styling approach and with server rendering? What is its accessibility record, bundle size, maintenance activity and licence (some advanced components are commercial)?
Key Takeaways
- Accessible, robust widgets are hard to build; component libraries and design systems prevent each team from rebuilding them.
- A design system combines tokens, components, guidelines and documentation; a component library is its code part.
- Fully styled libraries are fastest to adopt, headless libraries give full visual control, and copy-in approaches (shadcn/ui) give you ownership of the source.
- Shared components should forward native props and refs, expose limited variants, use tokens and be accessible by default.
- Storybook documents components in isolation and supports visual and accessibility testing.
Practice
- (Beginner) Install a fully styled library (MUI or Mantine) and build a book list page with its
Card,ButtonandTextFieldcomponents. Change the primary colour through its theme.- (Intermediate) Build a
Selecton top of a headless library (Radix or React Aria), style it with CSS Modules and design tokens, and test it with only the keyboard and with a screen reader. Write three Storybook stories for it.- (Interview) "Your company has three React apps with inconsistent UIs. How would you introduce a design system?" Hint: start from tokens and the most-used components, choose build vs buy (headless foundation), plan versioning and distribution as a package, documentation, adoption and ownership.
<a id="144-routing-data-and-state-management"></a>
14.4 Routing, Data and State Management
A real application has many screens, loads data from servers and shares state across distant components. This subchapter covers client-side routing with React Router, nested routes and guards, the patterns and pitfalls of loading data, a typed client for the Chapter 12 REST API, global state with Context and Redux, Redux Toolkit and its lighter alternatives, libraries specialised in caching server state, and live updates over WebSocket.
Client-Side Routing with React Router
Why client-side routing. In a traditional multi-page site every link triggers a full HTTP request, and the browser discards the current page, JavaScript state included, and loads a new one. A single-page application (SPA) loads one HTML page and then swaps views in JavaScript. Navigation becomes instant and state survives, but the application must take over the browser's routing duties: show the right view for the current URL, change the URL when the user navigates, support the Back and Forward buttons, deep links and bookmarks.
The browser provides the building blocks through the History API (Chapter 6): history.pushState() changes the URL without a request, and the popstate event fires on Back/Forward. A router library wraps them and maps URLs to components:
user clicks <Link to="/books/42">
-> router calls history.pushState(null, '', '/books/42') (no HTTP request)
-> router matches '/books/:id' and renders <BookDetails />
user presses Back
-> browser fires popstate -> router re-matches '/books' -> renders <BookList />
user reloads /books/42
-> real HTTP request: the server must return index.html for any app route (history fallback)
The last line is important: the server must answer every application URL with index.html and let the router take over (see "Serving SPAs and API Gateways" in 12.3).
| History mode | URL | Server configuration | Notes |
|---|---|---|---|
| Browser history | /books/42 | Needs a fallback to index.html | Clean URLs; the standard choice |
| Hash history | /#/books/42 | None (the hash is never sent to the server) | For static hosting without rewrites |
| Memory history | Not in the address bar | None | Tests, embedded widgets, React Native |
React Router. React Router is the most widely used router for React. Version 7 (late 2024) merged the former Remix framework into it and ships as a single react-router package (react-router-dom remains as a re-export for older imports). It can be used in three modes of increasing scope:
| Mode | Setup | Adds |
|---|---|---|
| Declarative | <BrowserRouter>, <Routes>, <Route> | URL matching, links, navigation hooks |
| Data | createBrowserRouter + <RouterProvider> | Route loaders and actions, pending states, error elements |
| Framework | Vite plugin, routes.ts, file conventions | Type-safe route modules, code splitting, server rendering |
Declarative mode.
// main.tsx
import { BrowserRouter, Routes, Route } from 'react-router';
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/books" element={<BookListPage />} />
<Route path="/books/:id" element={<BookDetailsPage />} /> {/* :id is a dynamic segment */}
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} /> {/* catch-all for unknown URLs */}
</Routes>
</BrowserRouter>,
);
React Router ranks routes by specificity, so the order of <Route> elements does not matter: /books/new wins over /books/:id for that URL.
Navigating. Use <Link> instead of <a>. A plain anchor causes a full page reload, losing all state. <Link> renders a real <a href>, so middle-click and "open in new tab" keep working, but it intercepts normal clicks and calls pushState. <NavLink> additionally knows whether it matches the current URL, which is useful for highlighting the active menu entry:
import { Link, NavLink, useNavigate } from 'react-router';
function NavBar() {
return (
<nav>
<NavLink to="/" end>Home</NavLink> {/* end: exact match only */}
<NavLink to="/books" className={({ isActive }) => (isActive ? 'active' : undefined)}>
Books
</NavLink>
</nav>
);
}
function BookRow({ book }) {
const navigate = useNavigate(); // programmatic navigation
async function handleDelete() {
await api.deleteBook(book.id);
navigate('/books', { replace: true }); // replace: do not leave a history entry
}
return (
<li>
<Link to={`/books/${book.id}`}>{book.title}</Link>
<button onClick={handleDelete}>Delete</button>
</li>
);
}
Data mode. In data mode, routes are defined as objects and each route can declare a loader that fetches its data before the route renders. Navigation then shows a pending state instead of rendering a component that immediately starts loading:
import { createBrowserRouter, RouterProvider, useLoaderData } from 'react-router';
const router = createBrowserRouter([
{
path: '/books/:id',
loader: async ({ params, request }) => {
const res = await fetch(`/api/v1/books/${params.id}`, { signal: request.signal }); // aborted if the user navigates away
if (res.status === 404) throw new Response('Not found', { status: 404 }); // shown by errorElement
return res.json();
},
Component: BookDetailsPage,
errorElement: <RouteError />,
},
]);
function BookDetailsPage() {
const book = useLoaderData(); // already loaded when the component renders
return <h1>{book.title}</h1>;
}
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);
Alternatives. TanStack Router offers fully type-safe routes and search parameters. Next.js (14.5) and React Router's framework mode use file-based routing, where the file system defines the routes.
Key Takeaways
- Client-side routing maps URLs to components and uses the History API (
pushState,popstate) to change views without page reloads. - The server must return
index.htmlfor every application route (history fallback) so deep links and reloads work. - React Router v7 ships as
react-routerand has declarative, data and framework modes. - Use
<Link>/<NavLink>instead of<a>for internal links anduseNavigatefor programmatic navigation. - Data mode loaders fetch data before a route renders and integrate with abort signals and error elements.
Practice
- (Beginner) Add React Router to the bookshelf app with routes for
/,/books,/books/:idand a 404 page, plus a navigation bar whose active link is highlighted.- (Intermediate) Convert the app to data mode: load the book list and book details with loaders, show a global "Loading..." bar using
useNavigation().state, and render a custom error element when the API returns404. Build it and serve it from Express with a history fallback, then reload a deep link.- (Interview) "A user reloads
https://app.example.com/books/42and gets a 404 from the server. Why, and how do you fix it?" Hint: explain what the server receives on reload in a SPA with browser history, and the fallback rule for non-API, non-asset paths.
Nested Routes, Params and Navigation Guards
Nested routes and layouts. Most applications share layout between pages: a header and sidebar around every page, a tab bar around every page in a section. Nested routes model this directly. A parent route renders the shared layout and an <Outlet /> where the matching child route appears, like a picture frame whose picture changes with the URL:
<Routes>
<Route element={<AppLayout />}> {/* layout route: no path, just wraps children */}
<Route index element={<HomePage />} /> {/* index route: renders at the parent's exact path */}
<Route path="books" element={<BooksLayout />}>
<Route index element={<BookListPage />} /> {/* /books */}
<Route path="new" element={<NewBookPage />} /> {/* /books/new */}
<Route path=":id" element={<BookDetailsPage />} /> {/* /books/42 */}
<Route path=":id/edit" element={<EditBookPage />} />
</Route>
</Route>
<Route path="login" element={<LoginPage />} /> {/* outside the layout */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
import { Outlet } from 'react-router';
function AppLayout() {
return (
<>
<NavBar />
<main><Outlet /></main> {/* child route renders here */}
<Footer />
</>
);
}
URL /books/42/edit
<AppLayout> NavBar + Footer stay mounted while navigating between books
<BooksLayout> section header ("Books") stays mounted
<EditBookPage /> only this part changes
Because layouts stay mounted, their state (an open sidebar, a scroll position) survives navigation between their children.
URL parameters. Dynamic segments are read with useParams. They are always strings (or undefined), so validate and convert them:
import { useParams } from 'react-router';
function BookDetailsPage() {
const { id } = useParams(); // '/books/42' -> { id: '42' }
const bookId = Number(id);
if (!Number.isInteger(bookId)) return <NotFoundPage />;
return <BookDetails id={bookId} />;
}
Search parameters as state. Filters, sorting and pagination belong in the query string (/books?author=herbert&page=2), not only in component state. The URL can then be bookmarked, shared and restored by Back. useSearchParams works like useState for the query string:
import { useSearchParams } from 'react-router';
function BookListPage() {
const [params, setParams] = useSearchParams();
const page = Number(params.get('page') ?? '1');
const author = params.get('author') ?? '';
function goTo(p: number) {
setParams((prev) => { prev.set('page', String(p)); return prev; }); // keeps other params
}
return (
<>
<input
value={author}
onChange={(e) => setParams({ author: e.target.value, page: '1' }, { replace: true })} // no history entry per keystroke
/>
<BookTable author={author} page={page} />
<button disabled={page === 1} onClick={() => goTo(page - 1)}>Previous</button>
<button onClick={() => goTo(page + 1)}>Next</button>
</>
);
}
Relative links. Inside nested routes, to values without a leading slash are relative to the current route: in BookDetailsPage, <Link to="edit"> goes to /books/42/edit and <Link to=".."> to the parent route.
Navigation guards. Some routes require a logged-in user or a specific role. A guard checks the condition before rendering and redirects otherwise. In declarative mode, a layout route can do this for all its children:
import { Navigate, Outlet, useLocation } from 'react-router';
function RequireAuth({ role }: { role?: 'admin' }) {
const { user, status } = useAuth(); // from the AuthProvider (14.2)
const location = useLocation();
if (status === 'checking') return <Spinner />; // do not redirect while the session is being restored
if (!user) {
// Remember where the user wanted to go, so login can send them back
return <Navigate to="/login" replace state={{ from: location }} />;
}
if (role && user.role !== role) return <Forbidden />;
return <Outlet />;
}
<Route element={<RequireAuth />}>
<Route path="books/new" element={<NewBookPage />} />
</Route>
<Route element={<RequireAuth role="admin" />}>
<Route path="admin/*" element={<AdminPage />} />
</Route>
// LoginPage: after a successful login, return to the original destination
const location = useLocation();
const navigate = useNavigate();
const from = location.state?.from?.pathname ?? '/';
await login(email, password);
navigate(from, { replace: true });
In data mode, the same check can run in a parent route's loader (if (!user) throw redirect('/login')), so protected data is never even requested.
Blocking navigation. To warn about unsaved changes, useBlocker intercepts in-app navigation, and a beforeunload listener covers closing the tab:
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname);
{blocker.state === 'blocked' && (
<ConfirmDialog title="Discard changes?" onConfirm={blocker.proceed} onCancel={blocker.reset} />
)}
Guards are UX, not security. Anyone can open DevTools and render any component, or call the API directly. A client-side guard only avoids showing pages that will not work. Every protected API endpoint must check authentication and authorisation on the server (see "API Keys and Role-Based Access Control" in 10.3).
Key Takeaways
- Nested routes render shared layouts with an
<Outlet />; layouts stay mounted while child routes change. useParamsreads dynamic segments as strings; validate and convert them.- Keep filters, sorting and pagination in the query string with
useSearchParamsso they are shareable and survive reloads. - Guards (a layout route with
<Navigate>, or a loader withredirect) send unauthenticated users to login and remember where they were going;useBlockerprotects unsaved changes. - Client-side guards improve UX only; the server must enforce access control.
Practice
- (Beginner) Create a
/bookssection with a layout that shows a sub-navigation ("All", "Add new") and an<Outlet />, plus index,newand:idchild routes.- (Intermediate) Store the book list's
author,sortandpagein the URL. Verify that Back restores the previous filter, that a copied link reproduces the same view, and that typing in the author field does not create one history entry per keystroke.- (Interview) "How do you implement protected routes in a React SPA, and why is that not enough to protect data?" Hint: describe a guard component or loader redirect, preserving the target location, handling the 'session still loading' state, and server-side enforcement of every API call.
Data Fetching and Loading States
The core difficulty. Fetching is asynchronous, so a component that displays remote data must handle several states, not just "here is the data". Forgetting any of them produces the classic bugs: a blank screen while loading, an infinite spinner after an error, an empty-looking list that is actually still loading.
Model the states explicitly. A request is in exactly one of a few states. Represent them with a discriminated union, not independent booleans, so impossible combinations cannot be represented:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Booleans allow nonsense: { isLoading: true, isError: true, data: [...] }
fetch() response ok
idle ---------> loading -------------> success
| |
| network/HTTP error | refetch
v v
error ---- retry ---> loading
function useBooks(author: string) {
const [state, setState] = useState<AsyncState<Book[]>>({ status: 'idle' });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
api.listBooks({ author }, controller.signal)
.then((page) => setState({ status: 'success', data: page.data }))
.catch((error) => {
if (!controller.signal.aborted) setState({ status: 'error', error });
});
return () => controller.abort();
}, [author]);
return state;
}
function BookListPage({ author }: { author: string }) {
const state = useBooks(author);
switch (state.status) {
case 'idle':
case 'loading':
return <BookListSkeleton />;
case 'error':
return <ErrorMessage error={state.error} />;
case 'success':
return state.data.length === 0
? <EmptyState message="No books by this author yet." /> // empty is not the same as loading
: <BookList books={state.data} />;
}
}
Loading UI that feels fast.
| Technique | Use when |
|---|---|
| Skeleton screen (grey shapes matching the final layout) | Content areas with a known shape; avoids layout shift |
| Spinner | Small areas or unknown shape; avoid full-page spinners |
| Delayed indicator (show only after ~200 ms) | Fast responses would otherwise cause a distracting flash |
| Keep previous data while refetching, dimmed | Pagination and filtering; avoids the content disappearing |
| Optimistic update | Mutations likely to succeed (likes, toggles); see "Consuming REST APIs from React" |
Waterfalls. When components fetch their own data in effects, a child cannot start fetching until its parent has loaded and rendered it. Requests that could run in parallel run one after the other:
Fetch-on-render (each component fetches in its own effect)
0ms |--- GET /user ---|
300ms |--- GET /user/7/shelves ---|
600ms |--- GET /shelves/3/books ---|
900ms page complete
Fetch in parallel (start all independent requests together)
0ms |--- GET /user ---|
0ms |--- GET /shelves (for current session) ---|
0ms |--- GET /books?shelf=3 ---|
300ms page complete
Ways to avoid waterfalls: start independent requests together (Promise.all); fetch at the route level with loaders (fetch-then-render) so data for the whole page starts loading on navigation; prefetch on hover or when a link becomes visible; and move data fetching to the server (14.5).
Three strategies.
| Strategy | How | Pros | Cons |
|---|---|---|---|
| Fetch-on-render | useEffect in each component | Simple, colocated | Waterfalls, no caching, race handling by hand |
| Fetch-then-render | Router loaders fetch before rendering the route | No waterfalls inside a route, data ready at render | Navigation waits for the slowest request |
| Render-as-you-fetch | Start fetching early, pass the promise, suspend where data is needed (use + Suspense) | Parallel, progressive display with Suspense boundaries | Needs a framework or library that caches promises |
Suspense for data with use. React 19's use(promise) reads a promise's value during render. If the promise is still pending, the component suspends, and the nearest <Suspense> boundary shows its fallback. If the promise rejects, the nearest error boundary catches it. The promise must be created outside render (by a loader, a cache or a parent) and kept stable; creating it inside the component would start a new request on every render.
import { Suspense, use } from 'react';
function BookDetails({ bookPromise }: { bookPromise: Promise<Book> }) {
const book = use(bookPromise); // suspends until resolved
return <h1>{book.title}</h1>;
}
function BookPage({ id }: { id: number }) {
const [bookPromise] = useState(() => api.getBook(id)); // created once, outside the child's render
return (
<ErrorBoundary fallback={<p>Could not load the book.</p>}>
<Suspense fallback={<BookSkeleton />}>
<BookDetails bookPromise={bookPromise} />
</Suspense>
</ErrorBoundary>
);
}
Suspense is covered in depth in 14.5. In practice, caching data libraries (see "Server State Caching Libraries") and frameworks manage the promises for you.
Key Takeaways
- Every remote-data view has loading, error, empty and success states; model them as a discriminated union, not independent booleans.
- Prefer skeletons, delayed indicators and keeping previous data to blank screens and flashing spinners.
- Fetching in each component's effect creates request waterfalls; start independent requests together or fetch at the route level.
- Strategies: fetch-on-render, fetch-then-render (loaders) and render-as-you-fetch (
use+ Suspense). use(promise)suspends until the promise resolves; the promise must be created outside render and cached.
Practice
- (Beginner) Build a book details page with explicit loading, error (with a "Retry" button), not-found and success states. Throttle the network in DevTools to check each one.
- (Intermediate) Create a page that needs the current user, their shelves and the books on the first shelf. Implement it with nested effects, record the waterfall in the Network panel, then restructure it to remove the waterfall and compare the timings.
- (Interview) "What is a request waterfall in a React app, and how do you avoid it?" Hint: explain why fetching in effects serialises requests through the component tree, and mention parallel requests, route loaders, prefetching and server-side data fetching.
Consuming REST APIs from React
Why a dedicated API layer. Scattering fetch calls across components duplicates the base URL, headers, JSON parsing and error handling, and every component ends up handling HTTP details differently. A small API client module centralises them. Components then call api.listBooks() and receive typed data or a meaningful error, just as the Express services in Chapter 12 hide the database behind a repository.
Types matching the API contract. The bookshelf API from 12.3 returns books, paginated lists and application/problem+json errors:
// src/api/types.ts
export type Book = {
id: number;
title: string;
author: string;
isbn: string;
year: number;
tags: string[];
};
export type NewBook = Omit<Book, 'id' | 'tags'> & { tags?: string[] };
export type Page<T> = { data: T[]; page: number; limit: number; total: number };
export type Problem = { // RFC 9457 problem details, as produced by the 12.2 error handler
type?: string;
title: string; // human-readable message, e.g. 'request validation failed'
status: number;
detail?: string;
code?: string; // machine-readable, e.g. 'VALIDATION_FAILED'
errors?: { in: 'params' | 'query' | 'body'; path: string; message: string }[];
requestId?: string;
};
The client.
// src/api/client.ts
import type { Book, NewBook, Page, Problem } from './types';
const BASE = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
export class ApiError extends Error {
constructor(public status: number, public problem: Problem) {
super(problem.detail ?? problem.title);
}
}
let accessToken: string | null = null; // kept in memory, not localStorage
export const setAccessToken = (t: string | null) => { accessToken = t; };
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
if (init.body) headers.set('Content-Type', 'application/json');
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
const res = await fetch(`${BASE}${path}`, { ...init, headers, credentials: 'include' }); // send cookies too
if (res.status === 204) return undefined as T; // DELETE: no body
const body = await res.json().catch(() => null); // error pages may not be JSON
if (!res.ok) {
if (res.status === 401) window.dispatchEvent(new Event('auth:expired')); // let the AuthProvider react
throw new ApiError(res.status, body ?? { title: res.statusText, status: res.status });
}
return body as T;
}
export const api = {
listBooks: (q: { page?: number; limit?: number; author?: string }, signal?: AbortSignal) => {
const params = new URLSearchParams();
Object.entries(q).forEach(([k, v]) => v !== undefined && v !== '' && params.set(k, String(v)));
return request<Page<Book>>(`/books?${params}`, { signal });
},
getBook: (id: number, signal?: AbortSignal) => request<Book>(`/books/${id}`, { signal }),
createBook: (b: NewBook) => request<Book>('/books', { method: 'POST', body: JSON.stringify(b) }),
updateBook: (id: number, patch: Partial<NewBook>) =>
request<Book>(`/books/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteBook: (id: number) => request<void>(`/books/${id}`, { method: 'DELETE' }),
};
The type assertion body as T trusts the server. For APIs you do not control, validate responses at runtime with the same kind of Zod schemas used on the server, or generate the client from an OpenAPI specification (for example with openapi-typescript).
Showing server validation errors. The API answers 422 with a list of field errors. Map them to the form:
async function handleSubmit(values: NewBook) {
try {
const book = await api.createBook(values);
navigate(`/books/${book.id}`);
} catch (e) {
if (e instanceof ApiError && e.status === 422) {
for (const err of e.problem.errors ?? []) setError(err.path as keyof NewBook, { message: err.message });
} else if (e instanceof ApiError && e.status === 409) {
setError('isbn', { message: 'A book with this ISBN already exists' });
} else {
setFormError('Could not save the book. Please try again.');
}
}
}
Pagination. The API returns { data, page, limit, total }, so the UI can compute the number of pages:
const totalPages = Math.max(1, Math.ceil(result.total / result.limit));
<Pager page={result.page} totalPages={totalPages} onChange={goTo} />
For endless scrolling, request the next page when a sentinel element at the end of the list becomes visible (IntersectionObserver), and append the results.
Optimistic updates. For actions that almost always succeed, update the UI immediately and roll back on failure. React 19's useOptimistic shows a temporary value while an async action is in progress:
import { useOptimistic, startTransition } from 'react';
function FavouriteButton({ book, onSaved }: { book: Book & { favourite: boolean }; onSaved: (b: Book) => void }) {
const [optimisticFav, setOptimisticFav] = useOptimistic(book.favourite);
function toggle() {
startTransition(async () => {
setOptimisticFav(!book.favourite); // shown immediately
const saved = await api.updateBook(book.id, { tags: toggleTag(book.tags, 'favourite') });
onSaved(saved); // real value replaces the optimistic one
}); // if it throws, the optimistic value is discarded
}
return <button aria-pressed={optimisticFav} onClick={toggle}>{optimisticFav ? 'Favourite' : 'Add to favourites'}</button>;
}
Cross-origin requests and authentication.
| Setup | What to configure |
|---|---|
| Development | Vite proxy (/api to localhost:3000): same origin for the browser, no CORS |
| Production, same origin | Serve the SPA from the API server or put both behind one reverse proxy |
| Production, different origins | CORS on the API (Access-Control-Allow-Origin for the app's origin; Allow-Credentials if cookies are used) |
Where to keep credentials matters. A token in localStorage is readable by any script on the page, so a single XSS vulnerability leaks it. Safer options are a session or refresh token in an HttpOnly, Secure, SameSite cookie, combined with a short-lived access token kept in memory (see "Access Tokens, Refresh Tokens and Revocation" in 10.3). Cookie-based authentication requires CSRF protection on state-changing requests.
Key Takeaways
- Centralise HTTP details (base URL, headers, JSON, errors, auth) in a typed API client; components call meaningful functions.
- Turn non-2xx responses into typed errors and map
422validation problems back to form fields. - Use the
totalandlimitfrom paginated responses to drive pagers or infinite scroll. - Optimistic updates (
useOptimistic) make mutations feel instant and roll back on failure. - Avoid CORS in development with a proxy; prefer
HttpOnlycookies plus in-memory access tokens overlocalStoragefor credentials.
Practice
- (Beginner) Write the API client above for the Chapter 12 bookshelf API and use it to show a paginated list with "Previous"/"Next" buttons and "Page X of Y".
- (Intermediate) Build create, edit and delete flows. Show
422errors next to their fields,409on the ISBN field, and make deletion optimistic with a rollback and an error toast if the server refuses. Test the rollback by stopping the server.- (Interview) "Where should a React SPA store an authentication token?" Hint: compare
localStorage, memory andHttpOnlycookies in terms of XSS, CSRF, page reloads and multiple tabs; mention refresh tokens.
Global State with Context and Redux
Kinds of state. Before choosing a "state management" tool, classify the state. Different kinds have different best homes:
| Kind | Examples | Best home |
|---|---|---|
| Local UI state | Is this dropdown open, input text | useState in the component |
| Shared UI state | Theme, sidebar collapsed, toasts | Context, or a small store |
| Server state (remote data cache) | Books, user profile, search results | A server-state library (see "Server State Caching Libraries") |
| URL state | Current page, filters, selected tab | The router (useParams, useSearchParams) |
| Form state | Field values, errors, dirty flags | The form or a form library |
| Complex client state | Editor documents, offline queues, wizards, undo history | useReducer, Redux or another store |
Many applications that "need Redux" turn out to need a server-state library plus a little context. True global client state is usually small.
Context plus useReducer. For moderate needs, combining the two hooks from 14.2 gives a lightweight store: a reducer defines the transitions, and context distributes state and dispatch:
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartDispatchContext value={dispatch}> {/* dispatch is stable: these consumers never re-render */}
<CartStateContext value={state}>{children}</CartStateContext>
</CartDispatchContext>
);
}
The limitation is granularity. Every consumer of CartStateContext re-renders when anything in the cart state changes, even if it only shows the item count. Context has no built-in selectors that subscribe to a slice of the state. For large, frequently changing global state, a store library is more efficient.
Redux. Redux (2015, Dan Abramov and Andrew Clark) applies the reducer idea to the whole application, inspired by the Flux architecture and the Elm language. Its three principles:
- Single source of truth: all global state lives in one object tree inside one store.
- State is read-only: the only way to change it is to dispatch an action, a plain object describing what happened.
- Changes are made with pure functions: reducers compute the next state from the previous state and the action.
+-------------------------------- store --------------------------------+
| |
UI ---dispatch(action)---> middleware (logging, async) ---> rootReducer(state, action)
^ | | |
| | new state |
| +----------------------------------------------------------|------------+
| |
+------ components re-render if their selected slice changed <----+
Classic Redux, for reading older code.
// Action types and action creators
const BOOK_ADDED = 'books/added';
const bookAdded = (book) => ({ type: BOOK_ADDED, payload: book });
// Reducer: pure, immutable
function booksReducer(state = { items: [] }, action) {
switch (action.type) {
case BOOK_ADDED:
return { ...state, items: [...state.items, action.payload] };
default:
return state;
}
}
// Store
import { createStore, combineReducers } from 'redux'; // createStore is deprecated in favour of RTK
const store = createStore(combineReducers({ books: booksReducer }));
store.dispatch(bookAdded({ id: 1, title: 'Dune' }));
store.getState(); // { books: { items: [{ id: 1, title: 'Dune' }] } }
Connecting React with react-redux. A <Provider> makes the store available, useSelector subscribes to a slice of state, and useDispatch returns dispatch. A component re-renders only when the value its selector returns changes (compared by reference), which solves the granularity problem of context:
import { Provider, useSelector, useDispatch } from 'react-redux';
<Provider store={store}><App /></Provider>
function CartBadge() {
const count = useSelector((state) => state.cart.items.length); // re-renders only when the count changes
return <span className="badge">{count}</span>;
}
function AddToCartButton({ book }) {
const dispatch = useDispatch();
return <button onClick={() => dispatch({ type: 'cart/itemAdded', payload: book })}>Add</button>;
}
What Redux gives you. Predictable updates in one place; the Redux DevTools browser extension, which shows every action with the state before and after, and lets you "time-travel" by replaying actions; middleware for cross-cutting concerns (logging, analytics, async logic); and easy testing of reducers and selectors. Its traditional cost was boilerplate: action type constants, action creators, switch statements and hand-written immutable updates for every feature. Redux Toolkit (next topic) removes most of it.
Key Takeaways
- Classify state first: local UI, shared UI, server, URL, form and complex client state have different best homes.
- Context plus
useReduceris a lightweight store, but every consumer re-renders on any change because context has no selectors. - Redux keeps global state in one store, changed only by dispatched actions processed by pure reducers.
useSelectorsubscribes to a slice and re-renders only when it changes;useDispatchsends actions.- Redux offers DevTools time travel, middleware and testability; classic Redux is verbose, which Redux Toolkit addresses.
Practice
- (Beginner) Implement a shopping cart with Context plus
useReducer(add, remove, change quantity) and display a badge in the header and a list in a drawer.- (Intermediate) Add a render counter to every cart consumer and observe how many re-render when a quantity changes. Re-implement the cart with classic Redux and
useSelector, and compare the counters.- (Interview) "How do you decide whether an application needs Redux?" Hint: classify the state, estimate how much is truly global client state, consider re-render granularity, debugging needs, team size and existing code, and mention server-state libraries.
Redux Toolkit and Alternatives
Redux Toolkit (RTK). Redux Toolkit is the official, recommended way to write Redux. It keeps the same architecture but removes the boilerplate: it configures the store with good defaults, generates action creators and types from reducers, and uses Immer so reducers can be written with "mutating" syntax that produces immutable updates.
npm install @reduxjs/toolkit react-redux
// src/features/cart/cartSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type CartItem = { bookId: number; title: string; quantity: number };
type CartState = { items: CartItem[] };
const initialState: CartState = { items: [] };
const cartSlice = createSlice({
name: 'cart', // prefix of generated action types: 'cart/itemAdded'
initialState,
reducers: {
itemAdded(state, action: PayloadAction<{ bookId: number; title: string }>) {
const existing = state.items.find((i) => i.bookId === action.payload.bookId);
if (existing) existing.quantity += 1; // looks like mutation: Immer turns it into a new object
else state.items.push({ ...action.payload, quantity: 1 });
},
itemRemoved(state, action: PayloadAction<number>) {
state.items = state.items.filter((i) => i.bookId !== action.payload);
},
cleared() {
return initialState; // returning a value replaces the state
},
},
});
export const { itemAdded, itemRemoved, cleared } = cartSlice.actions; // generated action creators
export default cartSlice.reducer;
// Selectors live next to the slice
export const selectCartCount = (s: RootState) => s.cart.items.reduce((n, i) => n + i.quantity, 0);
// src/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
import cartReducer from './features/cart/cartSlice';
export const store = configureStore({
reducer: { cart: cartReducer }, // combines slice reducers; DevTools and thunk enabled
}); // dev checks: accidental mutation, non-serialisable values
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppSelector = useSelector.withTypes<RootState>(); // typed hooks for the whole app
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
function CartBadge() {
const count = useAppSelector(selectCartCount);
return <span className="badge">{count}</span>;
}
function AddButton({ book }: { book: Book }) {
const dispatch = useAppDispatch();
return <button onClick={() => dispatch(itemAdded({ bookId: book.id, title: book.title }))}>Add to cart</button>;
}
Async logic with thunks. A thunk is a function dispatched instead of an action. Its job is to perform async work and dispatch real actions. createAsyncThunk generates pending, fulfilled and rejected actions:
import { createAsyncThunk } from '@reduxjs/toolkit';
export const checkout = createAsyncThunk('cart/checkout', async (_: void, { getState }) => {
const { cart } = getState() as RootState;
return api.createOrder(cart.items); // resolved value becomes action.payload
});
// in createSlice({ ..., extraReducers: (builder) => builder
// .addCase(checkout.pending, (s) => { s.status = 'submitting'; })
// .addCase(checkout.fulfilled, (s) => { s.items = []; s.status = 'idle'; })
// .addCase(checkout.rejected, (s, a) => { s.status = 'error'; s.error = a.error.message ?? null; }) })
For server data, RTK includes RTK Query (createApi), a caching data layer comparable to the libraries in the next topic.
Memoised selectors. A selector that derives a new array (state.books.filter(...)) returns a new reference every time, which re-renders the component on every store update. createSelector (from Reselect, re-exported by RTK) memoises derived data:
import { createSelector } from '@reduxjs/toolkit';
const selectItems = (s: RootState) => s.cart.items;
export const selectExpensiveItems = createSelector([selectItems], (items) => items.filter((i) => i.quantity > 2));
Alternatives. Redux is no longer the default answer. Lighter libraries cover most needs with less ceremony:
// Zustand: a store is a hook; components subscribe with selectors
import { create } from 'zustand';
type CartStore = {
items: CartItem[];
add: (b: { bookId: number; title: string }) => void;
remove: (id: number) => void;
};
export const useCart = create<CartStore>()((set) => ({
items: [],
add: (b) => set((s) => ({ items: [...s.items, { ...b, quantity: 1 }] })),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.bookId !== id) })),
}));
// No provider needed
const count = useCart((s) => s.items.length); // re-renders only when the length changes
const add = useCart((s) => s.add);
// Jotai: state split into small independent atoms, composed like useState
import { atom, useAtom, useAtomValue } from 'jotai';
const itemsAtom = atom<CartItem[]>([]);
const countAtom = atom((get) => get(itemsAtom).length); // derived atom
const count = useAtomValue(countAtom);
| Library | Model | Strengths | Consider when |
|---|---|---|---|
| Redux Toolkit | Single store, actions, reducers (Immer) | DevTools, middleware, conventions for large teams, RTK Query | Large apps, many developers, complex flows, existing Redux code |
| Zustand | Small hook-based stores with selectors | Minimal API, no provider, tiny | Most apps that need some global client state |
| Jotai | Atoms (bottom-up), derived atoms | Fine-grained updates, Suspense support | Many independent pieces of state, derived values |
| MobX | Observable objects, automatic tracking | Mutable style, very fine-grained reactivity | Teams comfortable with OOP and observables |
| XState | State machines and statecharts | Explicit states and transitions, visualisation | Complex workflows (checkout, wizards, media players) |
Context + useReducer | Built in | No dependency | Small to medium apps, rarely changing global state |
Key Takeaways
- Redux Toolkit is the standard way to write Redux:
configureStore,createSlicewith Immer, generated action creators and typed hooks. createAsyncThunkhandles async flows with pending/fulfilled/rejected actions; RTK Query handles server data.- Use memoised selectors (
createSelector) for derived data to avoid unnecessary re-renders. - Zustand, Jotai, MobX and XState are lighter or differently shaped alternatives; choose by the shape and size of your client state.
Practice
- (Beginner) Rewrite the cart from the previous topic with Redux Toolkit (
createSlice,configureStore, typed hooks) and inspect actions in Redux DevTools.- (Intermediate) Implement the same cart with Zustand. Add persistence to
localStoragewith Zustand'spersistmiddleware and a checkout thunk in the RTK version, and compare the code size and the developer experience of both.- (Interview) "How does Redux Toolkit let you write
state.items.push(x)in a reducer if Redux requires immutability?" Hint: explain Immer's draft proxy, structural sharing of the result, and what happens if you both mutate the draft and return a value.
Server State Caching Libraries
Server state is different. Data fetched from an API is not really your state. It is a cached copy of state that lives on the server. Other users can change it, it becomes stale, several components may request the same data at the same time, and it needs refetching, deduplication, pagination and invalidation after mutations. Managing it with useState + useEffect (or putting it into Redux by hand) means rebuilding a cache badly. Server-state libraries implement this cache for you.
Main features:
| Feature | Meaning |
|---|---|
| Caching by key | Results are stored under a key such as ['books', { author, page }]; other components reuse them |
| Deduplication | Ten components asking for the same key at the same time produce one request |
| Stale-while-revalidate | Show cached data immediately, refetch in the background, update when the new data arrives |
| Automatic refetch | On window focus, network reconnect, interval, or when the key changes |
| Invalidation | After a mutation, mark related keys stale so they refetch |
| Retries, pagination, infinite queries, prefetching, optimistic updates | Built in |
Stale-while-revalidate timeline for key ['books']
t=0 component mounts -> cache empty -> status 'pending' -> request #1
t=300 response -> cache = v1 -> status 'success', render v1
t=60s user leaves the tab, comes back -> data is stale -> render v1 immediately AND request #2
t=60.3s response -> cache = v2 -> re-render with v2 (no spinner shown)
TanStack Query. TanStack Query (formerly React Query) is the most widely used library of this kind.
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } }, // data is "fresh" for 30 s: no refetch within that time
});
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}><App /></QueryClientProvider>,
);
// Queries: read data
import { useQuery, keepPreviousData } from '@tanstack/react-query';
function BookListPage({ author, page }: { author: string; page: number }) {
const { data, isPending, isError, error, isFetching, refetch } = useQuery({
queryKey: ['books', { author, page }], // key changes -> new query
queryFn: ({ signal }) => api.listBooks({ author, page, limit: 20 }, signal), // aborted when no longer needed
placeholderData: keepPreviousData, // keep the old page visible while the next loads
});
if (isPending) return <BookListSkeleton />;
if (isError) return <ErrorMessage error={error} onRetry={refetch} />;
return (
<div style={{ opacity: isFetching ? 0.6 : 1 }}> {/* background refetch indicator */}
<BookList books={data.data} />
</div>
);
}
// Mutations: change data, then invalidate what may have changed
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useCreateBook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createBook,
onSuccess: (book) => {
queryClient.setQueryData(['book', book.id], book); // seed the details cache
queryClient.invalidateQueries({ queryKey: ['books'] }); // every list query refetches
},
});
}
function NewBookPage() {
const createBook = useCreateBook();
return (
<BookForm
onSubmit={(values) => createBook.mutateAsync(values)}
submitting={createBook.isPending}
/>
);
}
invalidateQueries({ queryKey: ['books'] }) matches every key that starts with ['books'], so all filtered and paginated lists are refreshed. Designing keys hierarchically (['books'], ['books', filters], ['book', id]) makes invalidation simple.
Two time settings to understand.
| Option | Default | Meaning |
|---|---|---|
staleTime | 0 | How long data counts as fresh. Fresh data is served from cache with no refetch |
gcTime | 5 minutes | How long unused data stays in memory after the last component using it unmounts |
With the default staleTime: 0, every new mount and every window focus triggers a background refetch. Raise it for data that changes rarely.
Optimistic updates with a cache.
useMutation({
mutationFn: (id: number) => api.deleteBook(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['books'] }); // avoid overwriting by in-flight fetches
const previous = queryClient.getQueriesData<Page<Book>>({ queryKey: ['books'] });
queryClient.setQueriesData<Page<Book>>({ queryKey: ['books'] }, (old) =>
old && { ...old, data: old.data.filter((b) => b.id !== id), total: old.total - 1 });
return { previous }; // context for rollback
},
onError: (_err, _id, ctx) => ctx?.previous.forEach(([key, data]) => queryClient.setQueryData(key, data)),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['books'] }), // resync with the server
});
Other libraries.
| Library | Notes |
|---|---|
| TanStack Query | Framework-agnostic, rich features, excellent DevTools |
| SWR (Vercel) | Smaller API built around the stale-while-revalidate idea: useSWR(key, fetcher) |
| RTK Query | Part of Redux Toolkit; endpoints defined once, generates hooks; cache tags for invalidation |
| Apollo Client, urql, Relay | GraphQL clients with normalised caches |
| Router loaders / Server Components | Fetch per route or on the server; often combined with one of the above |
With a server-state library, the remaining client state is usually small enough for useState, context or a tiny store.
Key Takeaways
- Server state is a cache of remote data: it can go stale, is shared, and needs deduplication, refetching and invalidation.
- Server-state libraries cache results by key and implement stale-while-revalidate, retries, pagination and optimistic updates.
- In TanStack Query,
useQueryreads byqueryKey,useMutationwrites, andinvalidateQueriesrefreshes related keys after a mutation. staleTimecontrols when data refetches;gcTimecontrols how long unused data stays in memory.- Moving server data to such a library usually removes most of the need for a global client-state store.
Practice
- (Beginner) Replace the hand-written
useBookshook withuseQuery. Open the TanStack Query DevTools, switch tabs and come back, and observe the background refetch. Then setstaleTimeto one minute and repeat.- (Intermediate) Implement create, update and delete with
useMutation, hierarchical keys and invalidation. Make delete optimistic with rollback, and prefetch a book's details when the user hovers over its link (queryClient.prefetchQuery).- (Interview) "Why not keep API data in Redux or in a global context?" Hint: list what a cache needs (staleness, deduplication, garbage collection, refetch triggers, invalidation, race handling) and how much code that is to write by hand.
Real-Time Updates with WebSocket
When polling is not enough. Chapter 10.5 introduced WebSocket, a persistent two-way connection over which the server can push messages, as well as server-sent events (SSE). In a React application, live features (a chat, presence indicators, "a new review was posted", stock levels) receive these messages and must merge them into the UI. The challenge is mostly about lifecycle: the connection is an external system, so it must be opened, kept and closed in step with the components that need it.
A connection in an effect. The simplest version opens a socket while a component is mounted:
function LiveReviews({ bookId }: { bookId: number }) {
const [reviews, setReviews] = useState<Review[]>([]);
useEffect(() => {
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
ws.addEventListener('open', () => ws.send(JSON.stringify({ type: 'subscribe', bookId })));
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'review.created' && msg.review.bookId === bookId) {
setReviews((prev) => [msg.review, ...prev]); // updater: never reads a stale list
}
});
return () => ws.close(); // book changed or unmounted
}, [bookId]);
return <ReviewList reviews={reviews} />;
}
This works, but each component that needs live data opens its own connection, and nothing reconnects after a network drop.
One shared connection. Usually an application keeps one socket and lets many components subscribe to the messages they care about. A small module outside React owns the connection and handles reconnection with exponential backoff:
// src/realtime/socket.ts
type Listener = (msg: ServerMessage) => void;
type Status = 'connecting' | 'open' | 'closed';
const listeners = new Set<Listener>();
const statusListeners = new Set<() => void>();
let ws: WebSocket | null = null;
let status: Status = 'closed';
let attempt = 0;
function setStatus(s: Status) { status = s; statusListeners.forEach((l) => l()); }
export function connect() {
setStatus('connecting');
ws = new WebSocket(`${location.origin.replace(/^http/, 'ws')}/ws`);
ws.onopen = () => { attempt = 0; setStatus('open'); };
ws.onmessage = (e) => { const msg = JSON.parse(e.data); listeners.forEach((l) => l(msg)); };
ws.onclose = () => {
setStatus('closed');
const delay = Math.min(30_000, 1000 * 2 ** attempt++) * (0.5 + Math.random()); // backoff with jitter
setTimeout(connect, delay);
};
}
export function subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener); // unsubscribe function
}
export function send(msg: ClientMessage) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
// For useSyncExternalStore
export const subscribeStatus = (cb: () => void) => { statusListeners.add(cb); return () => statusListeners.delete(cb); };
export const getStatus = () => status;
Reading an external store: useSyncExternalStore. The connection status lives outside React. useSyncExternalStore(subscribe, getSnapshot) is the hook designed to read such values safely. It re-renders when the store changes and avoids inconsistent values ("tearing") during concurrent rendering:
import { useSyncExternalStore } from 'react';
import { subscribeStatus, getStatus } from './socket';
export function useConnectionStatus() {
return useSyncExternalStore(subscribeStatus, getStatus);
}
function ConnectionBanner() {
const status = useConnectionStatus();
if (status === 'open') return null;
return <div role="status" className="banner">Reconnecting... live updates are paused.</div>;
}
Subscribing to messages with a hook.
export function useServerEvent<T extends ServerMessage['type']>(
type: T,
handler: (msg: Extract<ServerMessage, { type: T }>) => void,
) {
const onEvent = useEffectEvent(handler); // always the latest handler, not a dependency
useEffect(() => subscribe((msg) => {
if (msg.type === type) onEvent(msg as Extract<ServerMessage, { type: T }>);
}), [type]);
}
Merging pushed data into the server-state cache. When a server-state library holds the data, a pushed message can either update the cache directly or invalidate it so it refetches:
function RealtimeSync() {
const queryClient = useQueryClient();
useServerEvent('book.updated', ({ book }) => {
queryClient.setQueryData(['book', book.id], book); // patch the details cache directly
queryClient.invalidateQueries({ queryKey: ['books'] }); // lists may be affected: refetch
});
useServerEvent('book.deleted', ({ id }) => {
queryClient.removeQueries({ queryKey: ['book', id] });
queryClient.invalidateQueries({ queryKey: ['books'] });
});
return null; // a "behaviour-only" component
}
Sending small "something changed" events and letting the client refetch is simpler and safer than pushing full data. The REST API remains the authority (and enforces permissions), and messages missed during a disconnection are covered by refetching on reconnect.
Practical concerns.
| Concern | Approach |
|---|---|
| Strict Mode double effects | Ensure cleanup closes or unsubscribes; with a shared module, components only add and remove listeners |
| Missed messages while disconnected | On reconnect, invalidate queries (or request events since the last sequence number) |
| Authentication | Cookies are sent with the handshake; or send a token in the first message; never put long-lived tokens in the URL |
| High-frequency streams (prices, cursors) | Throttle updates, batch them per animation frame, update only the affected rows |
| Libraries | Socket.IO client (rooms, acknowledgements, automatic reconnection; needs a Socket.IO server), or SSE with EventSource for server-to-client only |
Key Takeaways
- A WebSocket connection is an external system: open it in an effect or a module, and always close or unsubscribe in cleanup.
- Prefer one shared connection with subscriptions and reconnection with exponential backoff and jitter.
useSyncExternalStorereads values from stores outside React (such as connection status) safely under concurrent rendering.- Merge pushed events into the server-state cache with
setQueryDataor invalidation; the REST API remains the source of truth. - Handle missed messages, authentication and high-frequency updates explicitly.
Practice
- (Beginner) Connect to the WebSocket server from Chapter 10.5 and render a live message list with an input to send messages. Verify under Strict Mode that only one connection stays open.
- (Intermediate) Add a WebSocket endpoint to the Chapter 12 API that broadcasts
book.created,book.updatedandbook.deletedevents. In the React app, keep one shared connection with backoff, show a "Reconnecting" banner, and update the TanStack Query cache. Open two browser windows and check that edits in one appear in the other.- (Interview) "How would you integrate a WebSocket feed into a React app that uses TanStack Query?" Hint: where the connection lives, how components subscribe,
setQueryDatavsinvalidateQueries, and what happens to updates missed during a disconnection.
<a id="145-testing-and-optimization"></a>
14.5 Testing and Optimization
The last subchapter is about shipping React applications that load quickly, stay responsive and keep working as they change. It covers splitting the bundle and loading code on demand, Suspense and concurrent rendering, finding and fixing slow renders, testing components the way users use them, rendering React on the server with Next.js, and how React compares with Angular and Vue.
Code Splitting and Lazy Loading
Why split. By default, a bundler follows every import from main.tsx and produces one JavaScript file containing the whole application. As the app grows, every visitor downloads, parses and executes code for pages they never open: the admin dashboard, the charting library, the rich-text editor. On a mid-range phone over a mobile network, a few megabytes of JavaScript can delay interactivity by seconds. Code splitting breaks the bundle into chunks that load on demand, like a restaurant kitchen that prepares dishes when they are ordered instead of cooking the whole menu at opening time.
The mechanism: dynamic import(). A static import is resolved at build time and lands in the same chunk. A dynamic import('./module') (Chapter 3) returns a promise. Bundlers treat it as a split point and emit the module and its exclusive dependencies as a separate file, fetched only when the call runs:
// The CSV library is downloaded only when the user clicks "Export"
async function handleExport(books) {
const { unparse } = await import('papaparse'); // separate chunk, loaded on demand
downloadFile('books.csv', unparse(books));
}
Lazy components. React.lazy turns a dynamic import of a component module into a component that suspends while its code loads. A <Suspense> boundary above it shows a fallback in the meantime:
import { lazy, Suspense } from 'react';
const AdminPage = lazy(() => import('./pages/AdminPage')); // module must have a default export
const StatsChart = lazy(() => // named export: map it to default
import('./components/StatsChart').then((m) => ({ default: m.StatsChart })),
);
function Dashboard({ showStats }: { showStats: boolean }) {
return (
<Suspense fallback={<ChartSkeleton />}>
{showStats && <StatsChart />} {/* chunk requested on first render */}
</Suspense>
);
}
Declare lazy components at module level, never inside a component. Calling lazy during render creates a new component type each time, which remounts it and loses its state (14.1).
Route-based splitting. Routes are the natural split points, because users visit one page at a time. Splitting per route gives the biggest win for the least effort:
const BookListPage = lazy(() => import('./pages/BookListPage'));
const BookDetailsPage = lazy(() => import('./pages/BookDetailsPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}> {/* one boundary for all routes */}
<Routes>
<Route path="/books" element={<BookListPage />} />
<Route path="/books/:id" element={<BookDetailsPage />} />
<Route path="/admin/*" element={<AdminPage />} />
</Routes>
</Suspense>
);
}
In React Router's data mode, a route's lazy property loads its component and loader in parallel with the data, avoiding a code-then-data waterfall. Framework mode and Next.js split every route automatically.
Before After
index-9f2c.js 1.4 MB index-a1b3.js 180 KB (shell, router, home)
BookListPage-7c1e.js 40 KB
BookDetailsPage-3d9a.js 35 KB
AdminPage-e2f0.js 620 KB (only for admins)
vendor-charts-5b7d.js 480 KB (only when stats are shown)
Preloading. Lazy loading adds a delay at the moment the user navigates. Hide it by starting the download a little earlier, when intent is likely:
const loadAdmin = () => import('./pages/AdminPage'); // the browser caches the module after the first call
const AdminPage = lazy(loadAdmin);
<Link to="/admin" onMouseEnter={loadAdmin} onFocus={loadAdmin}>Admin</Link>
What to split, and how to check. Good candidates are routes, heavy rarely used widgets (editors, charts, maps, PDF viewers) and content below the fold or behind interaction (modals, tabs). Bad candidates are small components shown on first render: each extra request has a cost, and splitting them only adds latency. Measure with a bundle analyser (rollup-plugin-visualizer for Vite, @next/bundle-analyzer for Next.js) and the browser's Network and Coverage panels. Also check that you import only what you use (import debounce from 'lodash-es/debounce' rather than the whole library), so that tree shaking can remove the rest.
Failures after a deploy. Chunk file names contain content hashes. If you deploy a new version while a user still has the old app open, their next lazy import requests a chunk that no longer exists and the import rejects. Mitigations: keep the previous build's assets available for a while, catch the error with an error boundary that offers to reload the page, and treat chunk-load errors as a signal to refresh.
Key Takeaways
- Code splitting turns dynamic
import()calls into separate chunks that load on demand, reducing the JavaScript needed for the first page. React.lazyplus<Suspense>lazy-loads components; declare lazy components at module level.- Split by route first, then by heavy, rarely used widgets; do not split small components needed on first render.
- Preload chunks on hover or focus to hide the delay; analyse bundles to find what to split.
- Plan for chunk-load failures after deployments (keep old assets, error boundary with reload).
Practice
- (Beginner) Lazy-load every page of the bookshelf app with
React.lazy, build the app, and compare the list of files and their sizes indist/assetsbefore and after.- (Intermediate) Add a heavy charting library to a statistics page, run a bundle analyser, and make sure it ends up only in that page's chunk. Add hover preloading to the navigation link and measure the navigation delay with the Network panel throttled to "Fast 4G".
- (Interview) "How would you reduce the initial JavaScript of a large React SPA?" Hint: measure first (bundle analyser, Coverage), route-based splitting, lazy heavy dependencies, tree-shakable imports, removing duplicated libraries, and server rendering or Server Components for content that needs no client code.
Suspense and Concurrent Rendering
Suspense: declaring loading states. Before Suspense, every component that waited for something (code or data) had to track its own isLoading flag and render its own spinner. A page could show five spinners popping in at random moments. <Suspense> lets a component suspend, that is, signal "I cannot render yet", and lets an ancestor decide what to show meanwhile. It works like try/catch for waiting: the boundary catches the "not ready" signal, shows its fallback, and renders the children when they are ready.
<Suspense fallback={<PageSkeleton />}>
<BookHeader id={id} /> {/* both must be ready before the page appears */}
<Suspense fallback={<ReviewsSkeleton />}> {/* nested: reviews may arrive later on their own */}
<Reviews bookId={id} />
</Suspense>
</Suspense>
time -> 0ms 200ms 900ms
outer: <PageSkeleton> header ready -> page shown
inner: <ReviewsSkeleton> reviews ready -> shown
You design the loading sequence by where you put boundaries. Everything inside one boundary appears together; nested boundaries reveal content progressively.
What can suspend. Lazy components (React.lazy), promises read with use (14.4), and data from Suspense-enabled libraries and frameworks (TanStack Query's useSuspenseQuery, Relay, Next.js Server Components). Data fetched in a plain useEffect does not suspend: the component has already rendered by the time the effect runs.
Concurrent rendering. Before React 18, rendering was synchronous: once React started rendering an update, it ran to completion, blocking the main thread. Typing into a search box that filtered a 10,000-row list could freeze each keystroke for 200 ms. With concurrent rendering (enabled by createRoot), React can prepare a new UI in the background, interrupt that work when something more urgent happens (a keystroke), and discard results that are already outdated. Pure rendering (14.1) is what makes this safe.
The tool for this is marking some updates as non-urgent transitions:
| Update type | Examples | Behaviour |
|---|---|---|
| Urgent | Typing, clicking, pressing | Must reflect immediately; never interrupted |
| Transition | Filtering results, switching tabs, navigating | May be interrupted, shows the old UI until the new one is ready |
useTransition.
import { useState, useTransition } from 'react';
function BookExplorer({ books }: { books: Book[] }) {
const [query, setQuery] = useState(''); // urgent: the input must stay responsive
const [filter, setFilter] = useState(''); // transition: the expensive list follows later
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // render the typed character right away
startTransition(() => setFilter(e.target.value)); // low priority; interrupted by the next keystroke
}
return (
<>
<input value={query} onChange={handleChange} aria-label="Filter books" />
<div style={{ opacity: isPending ? 0.5 : 1 }}> {/* old results stay visible, dimmed */}
<SlowBookList books={books} filter={filter} />
</div>
</>
);
}
Transitions also change how Suspense behaves. If an update made in a transition causes an already visible boundary to suspend (for example navigating to a page whose code is not loaded), React keeps showing the current UI instead of replacing it with a fallback. Routers wrap navigations in transitions for this reason: you stay on the old page with a progress indicator, rather than flashing a skeleton.
useDeferredValue. When you do not control the state update (the value comes from props or a library), useDeferredValue gives you a copy of the value that "lags behind" during heavy renders:
function Results({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query); // first renders with the old value, then the new one
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SlowBookList filter={deferredQuery} /> {/* memo(SlowBookList) lets React skip the urgent render */}
</div>
);
}
Actions. React 19 extends transitions to async functions. An async function passed to startTransition (or to a form's action, 14.3) is an action: React tracks it as pending until it settles and integrates it with useOptimistic and useActionState.
Concurrency is not parallelism. Rendering still happens on the single main thread (Chapter 5). React splits the work into small units and yields to the browser between them (roughly every 5 ms), so input events can be handled. Code inside a single component that takes 300 ms still blocks; transitions help when the cost is spread across many components.
Other concurrent features. Streaming server rendering sends HTML progressively as Suspense boundaries resolve (see "Server-Side Rendering with Next.js"). Selective hydration makes the parts of a server-rendered page interactive in the order the user interacts with them. <Activity> (React 19.2) pre-renders hidden UI at low priority.
Key Takeaways
<Suspense fallback>shows a fallback while descendants suspend (lazy code,use(promise), Suspense-enabled data libraries); boundary placement designs the loading sequence.- Concurrent rendering (enabled by
createRoot) lets React interrupt and discard non-urgent rendering work. useTransitionmarks state updates as non-urgent and exposesisPending;useDeferredValuedefers a value you receive.- Transitions keep the current UI visible instead of showing fallbacks again; async transitions are actions in React 19.
- Concurrency is cooperative scheduling on one thread, not parallelism; a single slow component still blocks.
Practice
- (Beginner) Create a page with two nested Suspense boundaries around lazy components with artificial delays (
new Promise((r) => setTimeout(r, 1500))before the import) and observe how fallbacks appear and disappear.- (Intermediate) Build a filter over 20,000 books where each row does some artificial work. Measure input lag with the Performance panel, then fix it once with
useTransitionand once withuseDeferredValue, and explain which you would choose and why.- (Interview) "What problem does concurrent rendering solve, and how does
useTransitionuse it?" Hint: blocking synchronous renders, urgent vs non-urgent updates, interruption and discarding of work, and why rendering must be pure.
Render Performance and Profiling
Measure before optimising. React is fast by default for most screens, and most "optimisations" applied blindly (wrapping everything in memo) add complexity without measurable benefit. Performance work follows a loop: measure a real slow interaction, find the cause, fix it with the simplest change, measure again.
What users feel. The Core Web Vitals give targets that relate to rendering:
| Metric | Measures | Good |
|---|---|---|
| LCP (Largest Contentful Paint) | When the main content appears | <= 2.5 s |
| INP (Interaction to Next Paint) | Delay from a click or key press until the next frame | <= 200 ms |
| CLS (Cumulative Layout Shift) | Unexpected movement of content | <= 0.1 |
Slow React renders hurt INP most directly; large bundles and client-side data waterfalls hurt LCP.
Tools.
- React DevTools Profiler: record an interaction and see which components rendered, how long each took (flame graph and ranked views) and, with "Record why each component rendered" enabled, why (props changed, state changed, parent rendered, context changed, hooks changed).
- Highlight updates when components render (React DevTools setting): flashes a border around components as they render.
- Browser Performance panel: shows long tasks on the main thread, including React's work, layout and paint. Use CPU throttling (4x or 6x slowdown) to mimic a mid-range phone.
<Profiler>component: measures render times in code, for example to send them to monitoring.
import { Profiler } from 'react';
<Profiler id="BookList" onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) console.warn(`${id} ${phase} took ${actualDuration.toFixed(1)} ms`); // over one frame
}}>
<BookList books={books} />
</Profiler>
Always profile a production build (npm run build && npm run preview, or React's profiling build). Development mode includes extra checks and Strict Mode double rendering, so it is several times slower.
Common causes and fixes. Most render performance problems come from rendering too many components or rendering too often, not from one slow component.
- State placed too high. Typing in a search box at the top of
Appre-renders the whole page. Move the state down into the component that uses it (colocation), or split the component so the fast-changing part is isolated:
// Before: every keystroke re-renders ExpensiveTree
function Page() {
const [text, setText] = useState('');
return (<><input value={text} onChange={(e) => setText(e.target.value)} /><ExpensiveTree /></>);
}
// After: the state lives in a small component; ExpensiveTree is untouched
function SearchInput() {
const [text, setText] = useState('');
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function Page() {
return (<><SearchInput /><ExpensiveTree /></>);
}
- Lift content up as
children. When a stateful wrapper must surround expensive content, pass the content aschildren. The elements are created by the parent, so they are the same objects when the wrapper re-renders, and React skips them:
function ScrollTracker({ children }: { children: React.ReactNode }) {
const [y, setY] = useState(0); // changes often
useEffect(() => {
const onScroll = () => setY(window.scrollY);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return <div><ProgressBar value={y} />{children}</div>; // children are not re-rendered on scroll
}
<ScrollTracker><ExpensiveArticle /></ScrollTracker>
-
Unstable props to memoised children. New objects, arrays and functions created on every render defeat
memo. Stabilise them withuseMemo/useCallback(14.2) or move constants outside the component. -
Context values that change too often. Every consumer re-renders. Memoise the provider value, split the context, or use a store with selectors (14.4).
-
Long lists. Rendering 10,000 rows creates 10,000 DOM nodes. Virtualisation (windowing) renders only the rows visible in the viewport plus a small buffer and reuses them while scrolling:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualBookList({ books }: { books: Book[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: books.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // estimated row height in px
overscan: 5, // extra rows above and below the viewport
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}> {/* full scroll height */}
{virtualizer.getVirtualItems().map((row) => (
<div key={row.key}
style={{ position: 'absolute', top: 0, transform: `translateY(${row.start}px)`, height: row.size, width: '100%' }}>
{books[row.index].title}
</div>
))}
</div>
</div>
);
}
-
Expensive work during render. Cache it with
useMemo, move it to the server, precompute it, or move it to a Web Worker. -
Urgent updates that cause heavy renders. Use transitions or deferred values (see "Suspense and Concurrent Rendering").
| Symptom in the Profiler | Likely cause | First fix to try |
|---|---|---|
| Whole page renders on each keystroke | State too high | Move state down, split components |
| Memoised child renders, "props changed" with identical-looking values | New object/function each render | Stabilise with useMemo/useCallback, or React Compiler |
| Many components render on "context changed" | Broad, frequently changing context | Split context, memoise value, use a store with selectors |
| One commit takes long with thousands of rows | Too many DOM nodes | Virtualisation, pagination |
| Single component with high self time | Heavy computation in render | useMemo, precompute, Web Worker |
The React Compiler. The React Compiler (stable since October 2025) is a build-time plugin that automatically memoises components and values. It removes most of causes 3 and part of 4 without manual memo, useMemo and useCallback. It relies on your code following the Rules of React (pure render, no mutation of props and state), and its ESLint plugin reports code it cannot optimise. It does not fix state placement, long lists or heavy computations; those are still design decisions.
Key Takeaways
- Measure first, in a production build, with the React DevTools Profiler and the browser Performance panel; INP is the metric most affected by slow renders.
- Most problems come from rendering too many components too often: colocate state, pass stable content as
children, keep props and context values stable. - Virtualise long lists; memoise or move expensive computations; use transitions for heavy non-urgent updates.
memo,useMemoanduseCallbackare targeted tools; the React Compiler applies them automatically when code follows the Rules of React.
Practice
- (Beginner) Record a Profiler session while typing in the bookshelf search box. Identify every component that renders per keystroke and the reason React DevTools gives for each.
- (Intermediate) Create a list of 10,000 books with a filter and a "select" checkbox per row. Profile it with 4x CPU throttling, then apply, one at a time, state colocation,
memowith stable callbacks, and virtualisation. Record INP-like timings after each step in a table.- (Interview) "A page in your React app feels slow when typing. How do you investigate and fix it?" Hint: reproduce in a production build with throttling, profile, read "why did this render", then fix in order of simplicity: state placement, children as props, stable references, virtualisation, transitions.
Testing with Jest and React Testing Library
What to test, and how. Component tests should give you confidence that the UI works for users and should survive refactoring. A test that checks internal state names or the component's private methods breaks whenever you restructure the code, even though users notice nothing. The guiding principle of React Testing Library (RTL) is: the more your tests resemble the way your software is used, the more confidence they can give you. Tests find elements the way users do (by role, label and text), interact the way users do (click, type), and assert on what users see.
/\ E2E (Playwright, Cypress): a few critical journeys in a real browser
/ \ against a real (or staging) back end
/----\
/ \ Integration/component tests (Vitest/Jest + RTL + MSW):
/ \ most tests; render a page or feature with mocked network
/----------\
/ \ Unit tests: reducers, selectors, hooks, utility functions
/--------------\
static analysis: TypeScript, ESLint (react-hooks)
The toolchain.
| Tool | Role |
|---|---|
| Jest or Vitest | Test runner: finds tests, runs them, provides describe/it/expect and mocking. Vitest reuses the Vite configuration and has a Jest-compatible API, so it is the natural choice in Vite projects; Jest is common in older and non-Vite projects |
| jsdom (or happy-dom) | A simulated DOM in Node.js, so components can render without a browser |
| @testing-library/react | render, screen and queries |
| @testing-library/user-event | Realistic user interactions (typing fires keydown, input, keyup) |
| @testing-library/jest-dom | Matchers such as toBeInTheDocument, toBeDisabled, toHaveValue |
| MSW (Mock Service Worker) | Intercepts network requests and returns mock responses, so components keep their real fetch code |
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom msw
// vite.config.ts (excerpt)
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true, // describe/it/expect without imports, like Jest
setupFiles: ['./src/test/setup.ts'],
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { cleanup } from '@testing-library/react';
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); // fail on unmocked requests
afterEach(() => { server.resetHandlers(); cleanup(); });
afterAll(() => server.close());
Queries and their priority. RTL queries come in three families: getBy* throws if nothing (or more than one element) matches, queryBy* returns null (to assert absence), and findBy* returns a promise that waits for the element (for async UI). Prefer queries that reflect accessibility, because they also verify that the UI is accessible:
| Priority | Query | Finds |
|---|---|---|
| 1 | getByRole('button', { name: /save/i }) | Elements by ARIA role and accessible name |
| 2 | getByLabelText('Title') | Form fields by their label |
| 3 | getByPlaceholderText, getByText, getByDisplayValue | Visible text |
| 4 | getByAltText, getByTitle | Images, titled elements |
| Last resort | getByTestId('book-row') | data-testid attributes, invisible to users |
A first component test.
// Rating.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { Rating } from './Rating';
it('reports the chosen rating', async () => {
const user = userEvent.setup();
const onChange = vi.fn(); // mock function (jest.fn() in Jest)
render(<Rating value={2} onChange={onChange} />);
expect(screen.getByRole('radio', { name: '2' })).toBeChecked(); // aria-checked is reflected
await user.click(screen.getByRole('radio', { name: '4' }));
expect(onChange).toHaveBeenCalledWith(4);
});
Testing with network: MSW. Mocking fetch or the API module ties tests to implementation details. MSW intercepts requests at the network level, so the component, the API client and the error handling all run for real:
// src/test/server.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
export const books = [
{ id: 1, title: 'Dune', author: 'Frank Herbert', isbn: '9780441172719', year: 1965, tags: [] },
{ id: 2, title: 'Emma', author: 'Jane Austen', isbn: '9780141439587', year: 1815, tags: [] },
];
export const server = setupServer(
http.get('/api/v1/books', ({ request }) => {
const author = new URL(request.url).searchParams.get('author')?.toLowerCase() ?? '';
const data = books.filter((b) => b.author.toLowerCase().includes(author));
return HttpResponse.json({ data, page: 1, limit: 20, total: data.length });
}),
);
// BookListPage.test.tsx
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { renderWithProviders } from '../test/utils';
import { BookListPage } from './BookListPage';
it('shows books and filters them by author', async () => {
const user = userEvent.setup();
renderWithProviders(<BookListPage />);
expect(await screen.findByText('Dune')).toBeInTheDocument(); // findBy waits for the fetch
expect(screen.getByText('Emma')).toBeInTheDocument();
await user.type(screen.getByLabelText(/author/i), 'austen');
expect(await screen.findByText('Emma')).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByText('Dune')); // Dune disappears after refetch
});
it('shows an error message when the API fails', async () => {
server.use( // override for this test only
http.get('/api/v1/books', () =>
HttpResponse.json({ title: 'internal server error', status: 500 }, { status: 500 })),
);
renderWithProviders(<BookListPage />);
expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i);
});
Components that use routers, query clients or stores need those providers. A small helper keeps tests short and gives each test a fresh cache:
// src/test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import { render } from '@testing-library/react';
export function renderWithProviders(ui: React.ReactElement, { route = '/' } = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); // fail fast in tests
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
Testing hooks and logic. Pure functions (reducers, selectors, formatters) are tested directly, without React. Custom hooks can be tested through a component that uses them or with renderHook:
import { renderHook, act } from '@testing-library/react';
it('debounces the value', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v, 300), { initialProps: { v: 'a' } });
rerender({ v: 'ab' });
expect(result.current).toBe('a'); // not yet updated
act(() => { vi.advanceTimersByTime(300); }); // act flushes the resulting state update
expect(result.current).toBe('ab');
vi.useRealTimers();
});
Good practices.
- Use
user-eventoverfireEvent; it simulates complete interactions (focus, key events, pointer events). - Await
findBy*for async UI instead of arbitrarysetTimeoutwaits; RTL wraps updates inact()for you. - Avoid large snapshot tests: they break on every markup change and are approved without reading. Small, targeted assertions document intent better.
- Do not test implementation details: no assertions on state variables, hook call counts or CSS class names users cannot perceive.
- Add automated accessibility checks (
vitest-axe/jest-axe) to component tests and Storybook. - Cover a few critical journeys (login, create a book, checkout) with end-to-end tests in Playwright, which drives real browsers against a running app.
Key Takeaways
- Test behaviour, not implementation: find elements by role and label, interact with
user-event, assert on what users see. - Vitest (Vite projects) or Jest runs tests in jsdom; RTL renders and queries; jest-dom adds DOM matchers.
- Use
getByfor present elements,queryByfor absence,findByfor async UI; prefer role and label queries. - Mock the network with MSW so the real API client and error handling are tested; wrap components in providers with a helper.
- Unit-test pure logic directly, use
renderHookfor hooks, keep snapshots small, and add a few Playwright E2E tests for critical flows.
Practice
- (Beginner) Test the
NewBookFormfrom 14.3: the submit button is disabled while the title is empty, typing a title enables it, and submitting callsonCreatewith the trimmed values.- (Intermediate) Write MSW-backed tests for the book list page covering loading, empty, error and success states, pagination and the author filter. Then refactor the page to use TanStack Query and verify that the tests pass unchanged.
- (Interview) "What does 'avoid testing implementation details' mean in React Testing Library?" Hint: give an example of a brittle test (state values, class names, snapshot) and its user-centric replacement, and explain why the replacement survives refactoring.
Server-Side Rendering with Next.js
The limits of client-side rendering. A Vite SPA sends an almost empty HTML page. The browser must download, parse and run the JavaScript, which then fetches data and finally renders content:
CSR (client-side rendering)
HTML (empty) -> JS bundle download -> JS executes -> fetch data -> render -> content visible
(and interactive)
SSR (server-side rendering)
server: fetch data -> render HTML -> send
browser: HTML visible (content!) -> JS download -> hydrate -> interactive
CSR has three weaknesses: a slow first contentful paint on slow devices and networks, content that crawlers and link-preview bots may not see, and data requests that start only after the JavaScript runs. Server-side rendering (SSR) runs the same React components on the server, sends complete HTML, and then hydrates it in the browser.
Hydration. The server's HTML is "dry": it looks right but has no event handlers. Hydration means React runs in the browser, renders the same component tree, matches it against the existing DOM, and attaches event listeners and state without recreating the nodes. For this to work, the first client render must produce exactly the same output as the server. Anything that differs between them causes a hydration mismatch: Date.now(), Math.random(), window.innerWidth, localStorage or locale-dependent formatting. Read browser-only values in an effect (which runs only on the client) after hydration.
Rendering strategies.
| Strategy | When HTML is produced | Fresh data | Typical use |
|---|---|---|---|
| CSR | In the browser | Yes, fetched by the client | Dashboards behind login, highly interactive tools |
| SSR (dynamic) | On the server, per request | Yes | Personalised or frequently changing pages |
| SSG (static generation) | At build time | Only as of the build | Documentation, marketing, blogs |
| ISR (incremental static regeneration) | At build time, regenerated in the background after N seconds or on demand | Eventually | Catalogues, product pages, news |
| Streaming SSR | On the server, sent progressively as Suspense boundaries resolve | Yes | Pages with fast and slow parts |
Next.js. Next.js (by Vercel) is the most widely used React framework. It provides file-based routing, all the rendering strategies above, React Server Components, server-side mutations, image and font optimisation, and an integrated build (Turbopack). Its modern router is the App Router (the app/ directory, introduced in Next.js 13). The older Pages Router (pages/ with getServerSideProps and getStaticProps) is still supported and common in existing projects.
npx create-next-app@latest bookshelf-next # TypeScript, ESLint, App Router, optional Tailwind
app/
+-- layout.tsx root layout: <html>, <body>, shared navigation
+-- page.tsx /
+-- books/
| +-- page.tsx /books
| +-- loading.tsx Suspense fallback for this segment (streaming)
| +-- error.tsx error boundary for this segment
| +-- [id]/
| +-- page.tsx /books/42 (dynamic segment)
| +-- not-found.tsx
+-- api/
+-- health/route.ts route handler: GET /api/health
Server Components and Client Components. In the App Router, components are React Server Components (RSC) by default. They run only on the server (at request time or at build time), can be async, can read databases and secrets directly, and send their rendered result to the browser without their JavaScript. Components that need state, effects, event handlers or browser APIs must be Client Components, marked with the 'use client' directive at the top of the file. Client Components are rendered to HTML on the server too, then hydrated in the browser.
// app/books/[id]/page.tsx (Server Component: no 'use client')
import { notFound } from 'next/navigation';
import { FavouriteButton } from './FavouriteButton';
type Props = { params: Promise<{ id: string }> }; // params are async in recent Next.js versions
export async function generateMetadata({ params }: Props) {
const { id } = await params;
const book = await getBook(Number(id));
return { title: book ? `${book.title} | Bookshelf` : 'Not found' }; // <title> and meta tags, rendered on the server
}
export default async function BookPage({ params }: Props) {
const { id } = await params;
const book = await getBook(Number(id)); // runs on the server: direct DB or API call, secrets stay there
if (!book) notFound(); // renders not-found.tsx with a 404 status
return (
<article>
<h1>{book.title}</h1>
<p>{book.author}, {book.year}</p>
<FavouriteButton bookId={book.id} /> {/* interactive island: a Client Component */}
</article>
);
}
async function getBook(id: number) {
const res = await fetch(`${process.env.API_URL}/api/v1/books/${id}`, {
next: { revalidate: 60 }, // ISR-style: cache the response for 60 s
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`API error ${res.status}`); // caught by error.tsx
return res.json() as Promise<Book>;
}
// app/books/[id]/FavouriteButton.tsx
'use client'; // this file and its imports ship to the browser
import { useState, useTransition } from 'react';
import { toggleFavourite } from './actions';
export function FavouriteButton({ bookId }: { bookId: number }) {
const [fav, setFav] = useState(false);
const [pending, startTransition] = useTransition();
return (
<button disabled={pending}
onClick={() => startTransition(async () => setFav(await toggleFavourite(bookId)))}>
{fav ? 'Remove favourite' : 'Add favourite'}
</button>
);
}
Server Functions (Server Actions). A function in a file marked 'use server' runs on the server but can be called from the client like an async function, or used as a form action. Next.js turns each call into a POST request behind the scenes:
// app/books/[id]/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
export async function toggleFavourite(bookId: number): Promise<boolean> {
const session = (await cookies()).get('sid')?.value;
if (!session) throw new Error('Not authenticated'); // a public endpoint: always check auth and validate input
const res = await fetch(`${process.env.API_URL}/api/v1/me/favourites/${bookId}`, {
method: 'POST',
headers: { Cookie: `sid=${session}` },
});
revalidatePath(`/books/${bookId}`); // refresh cached server-rendered output
return (await res.json()).favourite;
}
Streaming. loading.tsx wraps a route segment in a Suspense boundary. The server sends the layout and the fallback immediately, then streams the page's HTML when its data is ready. You can also place <Suspense> boundaries inside a page so a slow recommendations section does not delay the main content.
The Server/Client boundary.
| Server Component | Client Component ('use client') | |
|---|---|---|
| Runs | On the server only | Server (for HTML) and browser (hydration, interactivity) |
| JavaScript sent to the browser | None | The component and its imports |
async/await data access, secrets, DB | Yes | No (call APIs or Server Functions) |
useState, useEffect, event handlers, browser APIs | No | Yes |
| Can render the other kind | Yes, Client Components as children | Server Components only when passed in as children/props |
Props passed from a Server Component to a Client Component cross the network, so they must be serialisable: plain data, dates, promises and JSX, but no ordinary functions (Server Functions are the exception). Keep 'use client' as far down the tree as possible ("interactive islands"), so most of the page ships no JavaScript.
Alternatives and trade-offs. React Router's framework mode (the successor to Remix) offers SSR with loaders and actions, and TanStack Start offers SSR with type-safe routing. Astro renders mostly static pages with React islands. SSR needs a Node.js (or edge) runtime, adds server costs and operational complexity, and makes some mistakes (hydration mismatches, leaking secrets into Client Components) possible. For internal tools behind login, a Vite SPA is often the simpler and better choice. Caching APIs in Next.js have changed between major versions, so check the documentation of the version you use.
Key Takeaways
- CSR sends empty HTML and renders in the browser; SSR renders HTML on the server for faster first paint and better crawlability, then hydrates it.
- Hydration attaches React to server HTML and requires identical first renders; browser-only values belong in effects.
- Choose per page among CSR, SSR, SSG, ISR and streaming according to freshness, personalisation and cost.
- In the Next.js App Router, components are Server Components by default (async, server-only, no client JavaScript);
'use client'marks interactive Client Components;'use server'defines Server Functions. - Server Functions are public endpoints: authenticate and validate in them; keep client boundaries small.
Practice
- (Beginner) Create a Next.js App Router project with
/booksand/books/[id]pages that fetch from the Chapter 12 API in Server Components. View the page source to confirm that the book titles are in the HTML.- (Intermediate) Add a
loading.tsx, anerror.tsx, anot-found.tsxandgenerateMetadatato the details page, a Client Component that adds the book to favourites through a Server Function, and a slow "similar books" section streamed behind its own Suspense boundary. Compare the JavaScript sent to the browser with the Vite SPA version.- (Interview) "What is hydration, and what causes a hydration mismatch?" Hint: explain server HTML vs client render, the requirement that the first client render matches, and examples such as dates, random values,
windowaccess and browser extensions modifying the DOM.
Comparison with Angular and Vue
Three answers to the same problem. React, Angular and Vue all build component-based UIs that update when state changes, and all can produce excellent applications. They differ in how much they include, how you write templates and how they detect changes. Understanding the differences helps you choose for a project and move between codebases.
The same component in each. A counter with a doubled value and a button:
// React: a function; JSX; state via hooks; the function re-runs on each change
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
const doubled = count * 2; // recomputed on every render
return <button onClick={() => setCount(count + 1)}>{count} x 2 = {doubled}</button>;
}
// Angular: a decorated class; HTML template with its own syntax; signals for reactivity
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `<button (click)="increment()">{{ count() }} x 2 = {{ doubled() }}</button>`,
})
export class Counter {
count = signal(0);
doubled = computed(() => this.count() * 2); // recomputed only when count changes
increment() { this.count.update((c) => c + 1); }
}
<!-- Vue: a single-file component; HTML-based template; reactive refs (Composition API) -->
<script setup lang="ts">
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2); // tracked dependency: updates when count changes
</script>
<template>
<button @click="count++">{{ count }} x 2 = {{ doubled }}</button>
</template>
How each detects changes.
- React re-runs the component function when state changes and diffs the virtual DOM (14.1). Reactivity is explicit (setters) and coarse-grained (per component); memoisation or the React Compiler narrows it.
- Vue wraps state in JavaScript proxies (
ref,reactive). Reading a value during render records a dependency, and writing it re-renders exactly the components that read it. Templates are compiled with optimisation hints (static parts are skipped), and Vapor mode removes the virtual DOM for fine-grained DOM updates. - Angular historically used Zone.js to patch async APIs and run change detection across the component tree after any event. Modern Angular uses signals, fine-grained reactive values that notify exactly the views that depend on them, and new applications are zoneless by default.
Overview.
| Aspect | React | Angular | Vue |
|---|---|---|---|
| Kind | UI library; ecosystem chooses the rest | Complete framework ("batteries included") | Progressive framework: core plus official router and store |
| Maintained by | Meta and community | Independent core team (Evan You), community funded | |
| First release | 2013 | 2016 (rewrite of AngularJS from 2010) | 2014 |
| Language | JavaScript or TypeScript | TypeScript (required) | JavaScript or TypeScript |
| Templates | JSX: JavaScript expressions and map | HTML templates with @if, @for, bindings [prop], (event) | HTML templates with v-if, v-for, :prop, @event; JSX optional |
| Reactivity | State setters, re-render and virtual DOM diff | Signals (formerly Zone.js change detection) | Proxy-based reactive refs, dependency tracking |
| Data binding | One-way; controlled inputs | One-way plus two-way [(ngModel)] | One-way plus two-way v-model |
| Routing | React Router, TanStack Router (third party) | @angular/router (built in) | Vue Router (official) |
| State management | Context, Redux Toolkit, Zustand, TanStack Query... | Services with dependency injection, signals, NgRx | Pinia (official) |
| Forms | Controlled inputs, React Hook Form | Template-driven and reactive forms (built in) | v-model, VeeValidate, FormKit |
| HTTP | fetch + libraries | HttpClient (built in, RxJS based) | fetch + libraries |
| CLI and tooling | Vite, framework CLIs | Angular CLI (generation, build, test) | create-vue (Vite), Vue DevTools |
| SSR framework | Next.js, React Router, TanStack Start | Angular SSR (built in) | Nuxt |
| Mobile | React Native (native UI) | Ionic, NativeScript | Ionic, Capacitor, NativeScript |
| Learning curve | Small core; many ecosystem choices to learn | Steeper: TypeScript, DI, RxJS, many concepts, but one way to do things | Gentle; templates familiar to HTML/CSS developers |
| Typical fit | Large ecosystem, flexible architectures, cross-platform with React Native | Large enterprise teams wanting consistency and conventions | Teams wanting an approachable, cohesive framework; progressive adoption in existing pages |
Other options. Svelte compiles components to direct DOM updates and uses "runes" for reactivity. SolidJS uses JSX like React, but with signals and no virtual DOM: components run once, and only the affected DOM nodes update. Both show that the virtual DOM is one design among several. Their ideas (signals, compilation) have influenced React's Compiler, Angular's signals and Vue's Vapor mode.
Choosing. Technical differences matter less than they appear: all three are fast enough for nearly every application. Choose based on the team's experience, the hiring market where you are, the existing codebase, how much structure the team wants from the framework (Angular) versus how much freedom it wants (React), the ecosystem libraries you need, and mobile or SSR requirements.
Key Takeaways
- React is a UI library with a large ecosystem; Angular is a complete, opinionated TypeScript framework; Vue is a progressive framework with official router and store.
- React uses JSX and re-renders components with virtual DOM diffing; Angular and Vue use HTML-based templates and fine-grained reactivity (signals, proxies).
- Angular and Vue provide two-way binding syntax for forms; React uses controlled inputs and one-way flow.
- Svelte and SolidJS show alternatives without a virtual DOM, and their ideas are spreading to the others.
- Choose based on team skills, existing code, desired structure, ecosystem needs and platform targets, not on micro-benchmarks.
Practice
- (Beginner) Implement the same "book list with a search filter" component in React, Vue (
<script setup>) and Angular (standalone component with signals). Compare how each handles the input binding and the derived filtered list.- (Intermediate) Write a one-page technical recommendation choosing a framework for a new bookshelf admin application for a team of six developers who know TypeScript but not React. Cover routing, forms, state, testing and SSR needs, and justify the choice against the other two.
- (Interview) "How does change detection differ between React, Vue and Angular?" Hint: re-running components plus virtual DOM diffing, proxy-based dependency tracking, and Zone.js versus signals; discuss granularity and what developers must do to avoid unnecessary work in each.
<a id="15-hybrid-mobile-and-progressive-web-applications"></a>