Chapter 18
React, Hooks and Component Patterns
JSX, state, effects, custom hooks and advanced patterns.
18. React
React is a library for describing user interfaces as a function of changing data. Rather than imperatively editing each DOM node after every event, developers describe the desired interface for the current state. React compares successive descriptions and commits the necessary changes to the host environment, typically the browser DOM.
This chapter covers JSX, elements and component rendering, a conceptual model of Fiber, state updates and batching, Effects as synchronization, memoization tradeoffs, refs, custom hooks, Error Boundaries, and performance profiling with React DevTools.
Overview & Learning Objectives
By the end of this chapter, you will be able to:
- Explain what JSX compiles to and distinguish elements from component functions.
- Describe React's render and commit work and the role of Fiber without treating it as a DOM virtualizer.
- Use
useStateupdates correctly, including functional updates and batching. - Use Effects to synchronize with external systems and clean up correctly.
- Prevent Effect loops by identifying dependencies and removing unnecessary Effects.
- Evaluate
memo,useMemo, anduseCallbackbased on measured tradeoffs. - Use refs and custom hooks for imperative access and reusable stateful logic.
- Implement Error Boundaries and profile expensive rendering with React DevTools.
JSX, Elements, and Components
JSX is a syntax extension commonly transformed by the build tool into JavaScript calls that create React elements. JSX is not HTML in a JavaScript string, and a JSX element is not a live DOM node. It is a description of what React should render:
const heading = <h1 className="page-title">Frontend Fieldguide</h1>;The JSX transform produces calls to the automatic JSX runtime in modern setups. Older examples may show React.createElement; the exact generated form depends on the compiler. In either case, the result is a lightweight element description with a type, props, and children. React later uses that description during rendering.
Component names begin with a capital letter so JSX can distinguish a JavaScript component from an intrinsic HTML tag:
function Welcome({ name }) {
return <p>Hello, {name}.</p>;
}
const page = <Welcome name="Ari" />;<Welcome /> creates an element whose type refers to the Welcome function. It does not directly call that function in application code. React invokes components as part of its rendering process. Do not call a component as Welcome({ name: "Ari" }); doing so bypasses React's component identity and hook rules.
JSX expressions use JavaScript values. Use braces for expressions, and provide stable keys when rendering collections:
function LessonList({ lessons }) {
return (
<ul>
{lessons.map((lesson) => (
<li key={lesson.id}>{lesson.title}</li>
))}
</ul>
);
}Keys help React match siblings across renders. Use a stable identifier from the data rather than an array index when items can be reordered, inserted, or removed. A key is used by React's reconciliation and is not passed to the component as a normal prop.
Components should be pure with respect to rendering: for the same props, state, and context, they should return the same description and should not perform side effects while rendering. A render can be started, paused, repeated, or abandoned in concurrent-capable React. Mutating external state or issuing a network request during render can therefore produce duplicate or inconsistent effects.
Render, Reconciliation, and Fiber
When props, state, or context change, React renders affected components to calculate a new UI description. It compares the new element tree with the previous one and determines what changes are needed. The commit phase applies changes to the host environment and runs relevant lifecycle work. In a browser, this may include DOM updates, ref attachment, layout Effects, and passive Effects at their appropriate times.
Fiber is React's internal architecture for representing units of rendering work. A Fiber node stores information such as a component's type, props, state, relationships to other nodes, and scheduling work. Fiber lets React organize rendering as units that can be prioritized and, in suitable circumstances, interrupted and resumed. This supports features such as concurrent rendering and transitions.
Fiber is not simply “the virtual DOM” or a guarantee that every render happens in parallel. React can prepare work without immediately committing it, but DOM mutations occur during a commit. Rendering work must remain pure because React may evaluate a component more than once. Concurrent features do not make a single JavaScript function execute simultaneously on multiple CPU cores.
Reconciliation considers element type, position, and keys. If the component type at a position remains the same, React can usually preserve its state while updating props. If the type or key changes, React may unmount the previous component and create a new one, resetting local state. Use keys to express identity rather than as a performance trick. A key on a component can intentionally reset its state when the identity of the represented item changes.
State, useState, and Batching
useState gives a function component a state value and a setter. Calling the setter schedules a render; it does not mutate the state variable in the currently executing render:
function Counter() {
const [count, setCount] = React.useState(0);
function addOne() {
setCount(count + 1);
console.log(count); // the value captured by this render
}
return <button onClick={addOne}>Count: {count}</button>;
}Each render has its own snapshot of state and props. If a next value depends on the previous value, use the functional updater form:
function addThree() {
setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);
}Updater functions are queued and applied to the pending state. This differs from calling setCount(count + 1) three times in the same event, where each expression may use the same render snapshot and request the same next value.
React batches multiple state updates so it can avoid unnecessary intermediate renders. Modern React roots batch updates in more contexts than older versions did, including many promise and timer callbacks. Exact behavior depends on React version and root configuration, so do not rely on observing one render per setter call. If an imperative integration truly needs a synchronous DOM commit, React provides flushSync in the DOM package, but it should be rare because it defeats batching and can hurt responsiveness.
State should represent the minimal changing information. Derive values from state and props during render:
const remaining = Math.max(total - completed, 0);Do not mirror props into state without a clear reason, and do not update state during render to force a follow-up pass. For objects and arrays, create new values rather than mutating existing state:
setProfile((current) => ({ ...current, name: "Sam" }));
setTags((current) => [...current, "react"]);Direct mutation can make changes invisible to identity comparisons and can corrupt data shared by multiple renders. Keep state as local as practical, and lift it only when siblings need to coordinate.
Effects: Synchronizing with External Systems
useEffect synchronizes a component with something outside React: a browser event listener, network subscription, timer, imperative widget, or external store. It is not the general place to calculate values that can be derived during render, nor should it be used to respond to an event that can be handled directly by an event handler.
function OnlineStatus() {
const [online, setOnline] = React.useState(navigator.onLine);
React.useEffect(() => {
function updateStatus() {
setOnline(navigator.onLine);
}
window.addEventListener("online", updateStatus);
window.addEventListener("offline", updateStatus);
return () => {
window.removeEventListener("online", updateStatus);
window.removeEventListener("offline", updateStatus);
};
}, []);
return <p>{online ? "Online" : "Offline"}</p>;
}The Effect runs after React commits the component. Its cleanup runs before the Effect is set up again when dependencies change, and when the component is removed. In development, Strict Mode may perform an extra setup-cleanup cycle to help reveal unsafe Effects. This is not a reason to suppress cleanup or to add a “run once” ref as a workaround; make setup and cleanup symmetrical and safe.
Dependencies and stale values
The dependency array declares the reactive values an Effect uses. Include props, state, and functions or objects created during rendering that the Effect reads. The dependency list is not a scheduling preference; omitting dependencies can leave a closure using stale values. The React Hooks lint rules help detect missing dependencies and invalid hook usage.
function ChatRoom({ roomId }) {
React.useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <p>Connected to {roomId}</p>;
}When roomId changes, React cleans up the previous connection and creates one for the new room. If an Effect re-runs too often because a function or object is recreated, first simplify the Effect: move a helper inside it, calculate values directly there, or move non-reactive constants outside the component. useCallback and useMemo are not automatic fixes for every dependency warning.
Preventing infinite loops
An Effect can loop if it updates a dependency every time it runs. For example, setting state from an Effect and depending on a newly created object can cause repeated synchronization:
// Avoid: options is a new object on every render.
const options = { query, limit: 10 };
React.useEffect(() => {
setResults(search(options));
}, [options]);Prefer deriving synchronous values during render:
const results = search({ query, limit: 10 });If searching is an external asynchronous operation, the Effect should depend on the primitive input and handle stale results:
React.useEffect(() => {
let ignore = false;
async function load() {
const nextResults = await fetchResults(query);
if (!ignore) setResults(nextResults);
}
load();
return () => {
ignore = true;
};
}, [query]);For real fetch cancellation, use an AbortController if the API supports it. In larger applications, framework data loaders or a query library may handle caching, deduplication, and cancellation more robustly than an ad hoc Effect.
Ask whether an Effect is needed at all. If a user clicks Save, save in the click handler rather than setting shouldSave and adding an Effect to notice it. If a value is computed from props, calculate it during render. If a subscription is the purpose, make the Effect subscribe and unsubscribe. useLayoutEffect runs synchronously after DOM mutations and before paint; reserve it for layout measurement or mutation that must avoid a visible intermediate frame. It can block paint, so do not substitute it for every useEffect.
Memoization: memo, useMemo, and useCallback
Memoization can avoid repeated calculations or renders when the avoided work costs more than the memoization machinery. It also adds dependencies, memory retention, and conceptual complexity. Start with correct code and measure; do not wrap every value and function preemptively.
React.memo can skip rendering a component when its props compare equal by shallow comparison (unless a custom comparator is supplied). It is most useful for a component that renders often with stable props and performs meaningful work:
const LessonRow = React.memo(function LessonRow({ lesson, onOpen }) {
return <button onClick={() => onOpen(lesson.id)}>{lesson.title}</button>;
});If the parent creates a new onOpen function or lesson object on every render, the props are not shallowly equal. useCallback can preserve a function identity between renders while its dependencies remain unchanged:
const openLesson = React.useCallback((id) => {
navigate(`/lessons/${id}`);
}, [navigate]);useMemo caches a calculated value:
const visibleLessons = React.useMemo(
() => lessons.filter((lesson) => lesson.title.includes(query)),
[lessons, query],
);Use it when calculation cost is significant or stable identity is necessary for a measured memoization boundary. For a tiny filter over a few items, the added code may cost more than recalculating. Memoization is an optimization, not a semantic guarantee that a value will persist forever. Code must remain correct if React recalculates it.
When a memoized component still renders, inspect which prop changed. Avoid custom equality functions that ignore meaningful props or perform expensive deep comparisons; they can create stale UI or cost more than rendering. Also consider the broader data flow: splitting state so an unrelated update does not reach a subtree is often clearer than adding layers of memoization.
Refs and Imperative Access
A ref stores a mutable value that persists across renders without causing a render when it changes. DOM refs support focus, measurement, and integration with imperative APIs:
function SearchForm() {
const inputRef = React.useRef(null);
function focusSearch() {
inputRef.current?.focus();
}
return (
<>
<label>
Search
<input ref={inputRef} type="search" />
</label>
<button type="button" onClick={focusSearch}>Focus search field</button>
</>
);
}Use state instead when a value affects what should render. A ref is appropriate for a timer ID, an imperative instance, or the latest value needed by an external callback when updating it should not trigger rendering. Do not read or write refs during rendering in ways that make output depend on mutable hidden state. React attaches DOM refs during commit; expect current to be null before the element is mounted and after it is removed.
Imperative handles can expose a narrow API to a parent, but prefer declarative props when possible. In React 19, ref can be passed as a prop to function components; codebases using earlier React versions commonly use forwardRef. Follow the installed React version's API and avoid mixing examples without checking compatibility.
Custom Hooks
A custom hook extracts reusable stateful logic while keeping component markup owned by the caller. Its name begins with use, and it calls other hooks only at the top level of a component or custom hook:
function useReducedMotion() {
const [reduced, setReduced] = React.useState(() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
React.useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return reduced;
}
function AnimatedPanel() {
const reducedMotion = useReducedMotion();
return <section className={reducedMotion ? "panel" : "panel panel--animated"} />;
}Custom hooks share logic, not a single state instance. Each call to useReducedMotion has its own hook state and Effect unless it intentionally subscribes to shared external state. A hook should expose a small, meaningful contract rather than becoming a bag of unrelated utilities.
When subscribing to an external store, prefer React's useSyncExternalStore instead of manually reading a mutable store during render and subscribing in an Effect. It is designed to integrate external snapshots with concurrent rendering. For server rendering, provide a suitable server snapshot when required by the API.
Error Boundaries
Error Boundaries catch rendering errors in descendant components and display a fallback UI. They can also report the error and component stack. They do not catch errors in event handlers, ordinary asynchronous callbacks, server-side rendering, or errors thrown by the boundary itself. Handle those cases at their source or through the framework's error handling mechanisms.
As of React 19, class components remain the built-in API for defining an Error Boundary; many projects use a small class or a maintained boundary library. A minimal boundary looks like:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, info) {
reportRenderError(error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<section role="alert">
<h2>This section could not be loaded.</h2>
<button onClick={() => this.setState({ hasError: false })}>Try again</button>
</section>
);
}
return this.props.children;
}
}Place boundaries around meaningful recovery regions: a route, a widget, or a complex optional panel. A single boundary at the app root is still useful as a final fallback, but it may replace the entire interface for a small widget failure. Resetting only the boundary's state may not repair the underlying cause; retry logic should reload or reset the relevant feature state where appropriate. Do not expose stack traces or sensitive error details to end users.
React DevTools and Performance Profiling
React DevTools provides component inspection, props and state visibility, and a Profiler. Use the Profiler to record an interaction or render sequence, then inspect which components rendered and how much render time they consumed. A component can render because its own state changed, its parent rendered, a context value changed, or an external subscription notified it. A render does not necessarily imply a DOM update; React may determine that the committed output is unchanged.
Use the Profiler to find a concrete issue: a large list rerendering on each keystroke, a context update reaching an entire app, or expensive derived rendering. Compare the “before” and “after” profiles under the same scenario. React DevTools settings can highlight updates in development, but highlighting alone does not prove a user-visible performance problem.
Profile production-like builds where possible. Development builds include extra diagnostics and Strict Mode behavior, so timings may differ from production. For lower-level browser costs—long tasks, layout, paint, network requests—use Chrome DevTools Performance as described in the previous chapter. React profiling explains component work; the browser Performance panel explains the whole rendering pipeline. Together they provide a more complete diagnosis.
Exercises
Exercise 1: Fix a counter and Effect loop
The following component sometimes increments only once when the user clicks “Add three.” It also computes a filtered list in an Effect, which can loop because the filtered array is stored as state. Rewrite it using functional state updates and derived rendering.
function LessonSearch({ lessons }) {
const [count, setCount] = React.useState(0);
const [query, setQuery] = React.useState("");
const [visible, setVisible] = React.useState([]);
function addThree() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
React.useEffect(() => {
setVisible(lessons.filter((lesson) => lesson.title.includes(query)));
}, [lessons, query, visible]);
return <div>...</div>;
}Solution
Each setCount(count + 1) uses the same render snapshot. Functional updaters compose sequentially. The visible list is derived from lessons and query, so it should be calculated during rendering rather than copied into state and synchronized by an Effect:
function LessonSearch({ lessons }) {
const [count, setCount] = React.useState(0);
const [query, setQuery] = React.useState("");
function addThree() {
setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);
}
const visible = lessons.filter((lesson) =>
lesson.title.toLowerCase().includes(query.toLowerCase())
);
return (
<section>
<button onClick={addThree}>Add three (count: {count})</button>
<label>
Filter lessons
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<ul>
{visible.map((lesson) => <li key={lesson.id}>{lesson.title}</li>)}
</ul>
</section>
);
}If profiling later shows filtering a very large collection is costly, consider an index, a worker, or useMemo with the correct dependencies. Do not memoize solely to make the code function.
Exercise 2: Add an external subscription and recovery boundary
Create a component that subscribes to the browser's online/offline events, cleans up listeners, and displays the result. Then explain where to place an Error Boundary if a separate optional chart can fail during rendering.
Solution
The subscription is synchronization with an external browser system, so an Effect is appropriate. Cleanup removes the exact same listener functions registered by setup:
function NetworkStatus() {
const [online, setOnline] = React.useState(() =>
typeof navigator === "undefined" ? true : navigator.onLine
);
React.useEffect(() => {
const update = () => setOnline(navigator.onLine);
window.addEventListener("online", update);
window.addEventListener("offline", update);
return () => {
window.removeEventListener("online", update);
window.removeEventListener("offline", update);
};
}, []);
return (
<p role="status">
{online ? "Network connection available" : "You are offline"}
</p>
);
}Place a boundary around the optional chart so a chart rendering failure replaces that region with a useful fallback while leaving the rest of the page available:
<main>
<CourseSummary course={course} />
<ErrorBoundary>
<ProgressChart data={course.progress} />
</ErrorBoundary>
</main>The boundary can report the component stack for diagnostics and provide an actionable retry if the chart can recover. Network events themselves do not imply that every request succeeded, so label the status accurately; “online” means the browser reports network connectivity, not that a particular service is reachable.