Dinesh/ Blog
application engineering

MS Stack Ch 9 — React

Components, JSX, the rendering model, hooks (useState, useEffect, useMemo, useCallback, useRef, useReducer, useContext), custom hooks, error boundaries, Suspense, wrapping imperative libraries. React without the cargo cult.

34 min readChecking device speech…
Lesson preparation & details

Level: intermediate

By the end, you should be able to

  • Distinguish render, commit and effect ownership
  • Prevent stale async completions and validate fetched data
  • Build accessible forms and manage imperative widget cleanup

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 36 sections

Chapter 9 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

React describes an interface as a function of props, state and context. This chapter follows that model through rendering, effects, forms and imperative widgets. Examples target React 19.2 client components; most hook contracts also apply to React 18, while ref handling and newer APIs require explicit version awareness. No hiring claim or comparison with another framework is needed to understand the model.

The shipping-vs-expert gap is enormous. The shipping engineer writes a component, hits a re-render bug, sprinkles useMemo and useCallback until it stops, and moves on. The expert understands the rendering model — that React re-renders top-down whenever state changes, that hooks run in order on every render, that effect dependencies are how React decides when to run effects, that useMemo can reduce computational cost or stabilize a prop reference, but is not a semantic lifetime guarantee, that the right answer to "this re-renders too much" is almost always "move state down" or "pass primitives as props" rather than "memo everything". The expert's code has fewer useMemo/useCallback calls than the shipping engineer's, and fewer perf bugs.

You finish this chapter when you can build a non-trivial component from scratch using the right hooks, wire up a controlled form, integrate an imperative library through useRef and useEffect without leaking, write a custom hook that encapsulates a real concern, and explain why your colleagues' effect with [] deps is going to bite them.

Rendering model — Top-down re-render, virtual DOM diffing, keys, when React bails out.

Hooks fluency — useState, useEffect, useMemo, useCallback, useRef, useReducer, useContext.

Effect discipline — Dependencies, cleanup, stale closures, the "each effect does one thing" rule.

Forms — Controlled vs uncontrolled, lifting state up, when to reach for react-hook-form.

Imperative interop — Wrap chart libraries, editors, focus management with refs and effects.

Error boundaries + Suspense — What they catch, what they don't, where to put them.

Concepts and depth

Component model and JSX desugaring

A React component is a JavaScript function that returns a tree of React elements (and may, via hooks, hold state and side effects). JSX is sugar — <Button label="Save" /> desugars to React.createElement(Button, { label: 'Save' }) (or, with the automatic JSX runtime in React 17+, an internal _jsx(Button, { label: 'Save' })). The point: JSX is not HTML; it is a function-call syntax that produces plain JavaScript objects describing what to render.

The plain-objects part matters. <Button label="Save" /> produces an element carrying a type, props and key. Treat elements as opaque; the internal shape and React 19 ref handling are not a public object-construction contract. React takes those objects, reconciles them with the previous render, and decides what to update in the actual DOM. The component function itself is not the DOM node — it's the recipe React reruns whenever it needs to know what the UI should look like.

There are two consequences. First, components are pure functions of their props and state — they should not mutate anything outside themselves during render (mutation belongs in effects or event handlers). Second, React is free to call your component as often as it likes — once per state change, possibly multiple times for concurrent rendering. Code that assumes "this runs once" (incrementing a counter outside state, doing IO during render) breaks the first time React does a double-render in dev mode or a concurrent re-render in prod.

Good enough to ship

• Write function components • Render is a pure function of props + state • No side effects during render

Expert tier

• Reason about JSX as _jsx(...) calls • Know React may double-render in StrictMode dev • Understand server-rendered vs client-rendered component differences

Function components, props, children, fragments

Props flow down. A component receives an object of props and renders accordingly. children is a special prop containing whatever JSX was passed between the opening and closing tags — <Card>...content...</Card> makes the ...content... available as props.children. Type props with TypeScript (React.ReactNode for anything renderable, React.ReactElement when you need exactly one element).

Fragments (<>...</> or <React.Fragment>) let you return multiple elements from a component without a wrapping <div>. Use them whenever the wrapper would change layout or semantics — they have zero DOM cost. The long form (<React.Fragment key={x}>) is needed when you're using a fragment inside a list and need a key.

Prop drilling is when a prop is passed through layers of components just to reach a deeply nested consumer. The shipping engineer treats it as a smell to be solved with Context immediately. The expert treats it as a signal: prop drilling is bad when a value belongs at the bottom of the tree (then move state down), and acceptable when a value genuinely is global (theme, current user). Context is the right answer only sometimes.

type CardProps = { title: string; children: React.ReactNode };
 
function Card({ title, children }: CardProps) {
  return (
    <section className="card">
      <h2>{title}</h2>
      {children}
    </section>
  );
}
 
<Card title="Revenue">
  <Chart data={data} />
  <Legend />
</Card>

Good enough to ship

• Type props with TypeScript • Use children for composition • Reach for fragments instead of wrapper divs

Expert tier

• Distinguish ReactNode, ReactElement, JSX.Element • Use render-prop / slot patterns for flexible composition • Resist Context as the default solution to prop drilling

Rendering model — virtual DOM, reconciliation, keys

When state changes, React re-renders the component and all its descendants by default. Re-render does not mean "re-create DOM nodes" — it means "call the component functions again and produce a new tree of React elements". React then diffs that new tree against the previous one (reconciliation) and applies the DOM mutations its reconciliation algorithm determines needed to make the real DOM match. The virtual DOM is the in-memory tree; the diff is what makes updates cheap.

The diff is shallow per node: React compares element types (<div> vs <span>) and bails out if they differ (it replaces the subtree). For lists of siblings, React uses keys to match up elements across renders. Without keys (or with index-as-key on a reorderable list), React mis-pairs elements and produces silently wrong renders — focused inputs lose their focus, animations restart, state attached to children gets shifted. Use stable IDs as keys; never use the array index for lists that reorder or have insertions in the middle.

The bail-out tools: React.memo(Component) can skip a parent-driven render when props compare equal (normally Object.is per prop). Its own state/context updates can still render it, and memoization is not a guarantee. useMemo and useCallback give you stable references for objects/functions you pass as props. Use these after profiling, not preemptively — most of them are wasted effort and add their own cost.

// Bad: index key for a reorderable list
{users.map((u, i) => <Row key={i} user={u} />)}
 
// Good: stable ID key
{users.map((u) => <Row key={u.id} user={u} />)}

The default is to re-render

React normally visits descendant elements after a state update, but same-state bailouts, memoization and reused element identity can skip work. This is usually fine — virtual DOM diffing is fast. Reach for memo/useMemo/useCallback only when the profiler shows a real bottleneck.

Good enough to ship

• Always provide stable keys for lists • Never sprinkle memo/useMemo until profiling • Move state down to localise re-renders

Expert tier

• Read React DevTools profiler to find render hotspots • Use React Compiler (auto-memoization) when stable • Reason about subtree replacement vs in-place update

useState

const [count, setCount] = useState(0) declares a piece of state. React tracks it per-component-instance and returns the current value plus a setter on every render. The setter accepts either a new value (setCount(5)) or a function of the previous value (setCount(c => c + 1)). Prefer the updater form when calculating from pending state, especially for multiple queued updates. A direct setCount(count + 1) is also correct for a single increment in an ordinary click handler; the updater avoids reusing one render snapshot when several updates must compose.

State updates are asynchronous and batched. setCount(1); setCount(2); in the same handler produces one render with count === 2, not two renders. State setters do not "return the new value" — reading count immediately after setCount(5) still gives you the old value because the component re-renders with the new value next.

The lazy initial value (useState(() => expensiveDefault())) is used for initialization rather than every ordinary render; Strict Mode may call it twice in development to detect impurity. Use it for any non-trivial initial state. Avoid storing derived values in state — derive them in render or with useMemo so they stay in sync with their inputs automatically.

function Counter() {
  const [count, setCount] = useState(0);
  const [items] = useState(() => loadFromStorage()); // lazy init
 
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Clicked {count} times — {items.length} items
    </button>
  );
}

Good enough to ship

• Use the function form when next depends on prev • Lazy init for expensive defaults • Never store derived values in state

Expert tier

• Know automatic batching across async boundaries (React 18+) • Use useReducer when state has interrelated fields • Reach for flushSync only when you must read DOM mid-handler

useEffect — dependencies, cleanup, the rules

useEffect(fn, deps) runs after a committed render when dependencies changed by Object.is, with cleanup for the previous setup before the next one. Effects do not run for discarded renders or during server rendering. An empty array means no reactive dependencies, not “once in the application”: remounting and development Strict Mode can run setup again. Omitting dependencies runs after every commit and is appropriate only when that synchronization is intentional.

Two rules you must internalise:

  1. List every reactive value used inside the effect in the deps array. This is what react-hooks/exhaustive-deps enforces. Lying — passing [] when the effect closes over a prop — produces stale closure bugs: the effect uses the value from the render where it was created, not the current value. The fix is not to suppress the lint rule; it's to fix the dependencies.
  2. Return a cleanup function. Anything you set up in an effect (a subscription, a timer, an event listener, an instance of a third-party library) must be torn down in cleanup. React calls cleanup when the effect re-runs (because deps changed) and when the component unmounts.

The third rule, more philosophical: each effect does one thing. If an effect both fetches and subscribes, split them into two effects with different dependencies. This makes deps lists honest and cleanup obvious.

useEffect(() => {
  const controller = new AbortController();
  let active = true;
  setError(null);
  setUser(null); // this view clears the previous user while the new request loads
  fetch(`/api/users/${encodeURIComponent(id)}`, { signal: controller.signal })
    .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
    .then(raw => parseUser(raw)) // stable domain parser, defined outside this component
    .then(user => { if (active) setUser(user); })
    .catch(e => { if (active) setError(e instanceof Error ? e : new Error('Request failed')); });
  return () => { active = false; controller.abort(); };
}, [id]); // state setters are stable; a reactive parser would also be a dependency

Strict Mode checks effects in development

With Strict Mode enabled at the root, React performs an extra development-only effect setup → cleanup → setup check. It is not a claim that every production component unmounts twice; subtree-only Strict Mode has additional caveats. If your effect breaks under this, your cleanup is wrong. Don't disable Strict Mode — fix the cleanup.

Good enough to ship

• List every reactive value used by the effect in deps • Always return a cleanup if you set anything up • One effect, one concern

Expert tier

• Recognise stale closures from the trace alone • Reach for refs when an effect needs to read "latest" without re-running • Replace fetch-in-effect with a query library at the right boundary

useMemo, useCallback, and when not to use them

useMemo(fn, deps) caches a calculation while dependencies remain equal, but React may discard that cache; code correctness must not rely on it. useCallback(fn, deps) is useMemo for functions — it reuses the function while deps are equal, subject to cache invalidation such as suspension during initial mount. Both exist for two reasons: computational cost (avoid recomputing an expensive value) and referential stability (pass the same object/function to a memoised child so it doesn't re-render).

The trap: most uses are neither cost nor stability — they're cargo cult. useMemo(() => x * 2, [x]) is pure overhead; the multiply is cheaper than the memo bookkeeping. useCallback(handleClick, []) on a button that doesn't hand the function to a memoised child does nothing useful. Profile first; memoise second.

When you do need referential stability: pass primitives down (strings, numbers, booleans are stable by value), or memoise the object once. When you do need computational caching: only when the work is meaningfully expensive (parsing a large blob, doing heavy data transforms, generating chart options).

React Compiler is separately configured build tooling that can memoize based on dependency analysis; merely installing React 19 does not enable it. When it's stable in your project, it eliminates most hand-written useMemo/useCallback. Until then: profile, then memoise.

// Bad: memo doing nothing useful
const doubled = useMemo(() => x * 2, [x]);
 
// Good: stabilise an object passed to a memo child
const config = useMemo(() => ({ url, headers: { 'X-User': userId } }), [url, userId]);
<MemoFetcher config={config} />

Good enough to ship

• Don't reach for memo until profiling • Use useMemo for expensive computation • Use useCallback for a measured memo-child benefit or a needed stable hook dependency

Expert tier

• Read DevTools Profiler flame graph to find real costs • Adopt React Compiler where stable; drop manual memo • Prefer state-local-to-leaf over global memo gymnastics

useRef, useLayoutEffect, useId

useRef(initial) returns a mutable container ({ current: initial }) that persists across renders without triggering re-renders when mutated. Two uses: DOM references (<input ref={inputRef} /> then inputRef.current.focus()), and mutable values that shouldn't cause re-render (a timeout ID, the latest props captured for an event handler, an instance of a third-party library you wrap).

useLayoutEffect runs synchronously after DOM mutations and before the browser paints. Use it (sparingly) when you need to measure the DOM and mutate it before the user sees anything — animations starting from a measured position, tooltips that need to know their width to position themselves. Default to useEffect; reach for useLayoutEffect only when paint flicker would otherwise be visible.

useId returns an ID that matches across server and client renders when their component trees match — use it for htmlFor / id pairs in components that may render multiple times on a page. Don't use random or counter-based IDs in render; they will mismatch between server and client and break hydration.

function Modal({ onClose }: { onClose: () => void }) {
  const dialog = useRef<HTMLDialogElement>(null);
  const titleId = useId();
 
  useLayoutEffect(() => {
    const node = dialog.current;
    const previous = document.activeElement;
    node?.showModal();
    return () => {
      node?.close();
      if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
    };
  }, []);
 
  return (
    <dialog ref={dialog} aria-labelledby={titleId}
      onCancel={e => { e.preventDefault(); onClose(); }}>
      <h2 id={titleId}>Confirm</h2>
      <p>Review the action before continuing.</p>
      <button type="button" onClick={onClose}>Close</button>
    </dialog>
  );
} // The parent removes this component onClose; no action is performed here.

Good enough to ship

• useRef for DOM access and mutable non-render values • useId for accessible label/control pairings • useLayoutEffect only for pre-paint DOM measure/mutate

Expert tier

• Use a ref prop in React 19; forwardRef remains the React 18-compatible wrapper pattern • Use callback refs for measure-on-mount patterns • Know SSR pitfalls of useLayoutEffect (use useEffect fallback)

useContext and useReducer

useContext(MyContext) reads the nearest provider's value. Context is for genuinely tree-wide values: theme, current user, i18n locale, a router instance. It is not a state management library — every consumer re-renders whenever the provider's value changes by reference, with no fine-grained subscription. Pass primitives or memoised objects as the value.

The performance pitfall: a context whose value is a fresh object every render causes every consumer to re-render every time. Memoise the value (useMemo(() => ({ user, setUser }), [user])), or split the context into "value" and "setter" contexts so consumers can subscribe to only the half they need.

useReducer(reducer, initial) is useState for state with interrelated fields or complex transitions. The reducer pattern (action → new state) makes state changes explicit and testable. Pair it with a discriminated-union action type for type-safe dispatching. Reach for useReducer over useState when explicit transitions help keep related fields consistent; there is no three-field threshold.

type Cart = { items: { id: string; qty: number }[] };
type Action =
  | { type: 'add'; id: string; qty: number }
  | { type: 'remove'; id: string }
  | { type: 'clear' };
 
function cartReducer(state: Cart, action: Action): Cart {
  switch (action.type) {
    case 'add':    return { ...state, items: [...state.items, { id: action.id, qty: action.qty }] };
    case 'remove': return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'clear':  return { ...state, items: [] };
  }
}
 
// Inside a component: const [cart, dispatch] = useReducer(cartReducer, { items: [] });
// In an event handler: dispatch({ type: 'add', id: 'X1', qty: 1 });

Good enough to ship

• Context for theme, user, locale • Memoise context values • useReducer for related-field state

Expert tier

• Split value/setter contexts for performance • Use use-context-selector or Zustand for fine-grained subscriptions • Test reducers as pure functions in isolation

Rules of hooks

State/effect hooks must be called unconditionally and in the same order on every render. React 19’s special use API may be used in loops/conditions, but still only in components/hooks and not inside try/catch; it is not permission to conditionally call useState. React tracks them positionally — the first useState call you make is "state slot 0", the second is "state slot 1". If you wrap a hook in an if, React loses the mapping and either crashes or returns the wrong slot.

For ordinary Hooks (the special use API above is the exception), the two rules are:

  1. Only call hooks at the top level — not inside conditionals, loops, or nested functions.
  2. Only call hooks from React functions — components or other hooks.

The lint rule react-hooks/rules-of-hooks catches violations. Turn it on, treat its errors as build failures. If you find yourself wanting to call a hook conditionally, extract a smaller component and conditionally render that.

// Bad
function Comp({ enabled }: { enabled: boolean }) {
  if (enabled) {
    const [x, setX] = useState(0); // ❌ conditional hook
  }
}
 
// Good
function Comp({ enabled }: { enabled: boolean }) {
  return enabled ? <Inner /> : null;
}
function Inner() {
  const [x, setX] = useState(0); // ✅ always called
}

Good enough to ship

• Top-level hooks only • ESLint rules-of-hooks on • Extract components instead of conditional hooks

Expert tier

• Reason about hook order in dev tools • Write custom hooks that follow the same rules • Handle conditional state via separate components or null state

Controlled vs uncontrolled forms

Controlled inputs derive their value from React state — <input value={x} onChange={e => setX(e.target.value)} />. React owns the truth; every keystroke triggers a re-render. Useful when you need to react to input (live validation, formatters) or read the value reliably from anywhere in the tree.

Uncontrolled inputs let the DOM own the value — <input defaultValue="..." ref={inputRef} /> then read inputRef.current.value on submit. Useful for large forms where you don't care about every keystroke and want to avoid the re-render churn.

For real-world forms with validation, side effects, and conditional fields, reach for react-hook-form (or Formik in legacy projects). It uses uncontrolled inputs under the hood, registers them via refs, and gives you a handleSubmit that validates and produces a typed values object. Pair with Zod via @hookform/resolvers for end-to-end type-safe validation.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
 
const Schema = z.object({ email: z.string().email(), password: z.string().min(8) });
type FormValues = z.infer<typeof Schema>;
 
function LoginForm({ onLogin }: { onLogin: (v: FormValues) => Promise<void> }) {
  const emailId = useId();
  const passwordId = useId();
  const { register, handleSubmit, formState } = useForm<FormValues>({
    resolver: zodResolver(Schema),
  });
  return (
    <form onSubmit={handleSubmit(onLogin)}>
      <label htmlFor={emailId}>Email</label>
      <input id={emailId} type="email" autoComplete="username" {...register('email')}
        aria-invalid={!!formState.errors.email}
        aria-describedby={formState.errors.email ? `${emailId}-error` : undefined} />
      {formState.errors.email && <p id={`${emailId}-error`} role="alert">{formState.errors.email.message}</p>}
      <label htmlFor={passwordId}>Password</label>
      <input id={passwordId} type="password" autoComplete="current-password" {...register('password')}
        aria-invalid={!!formState.errors.password}
        aria-describedby={formState.errors.password ? `${passwordId}-error` : undefined} />
      {formState.errors.password && <p id={`${passwordId}-error`} role="alert">{formState.errors.password.message}</p>}
      <button disabled={formState.isSubmitting}>Sign in</button>
    </form>
  );
}

Good enough to ship

• Controlled for small forms with live behaviour • Uncontrolled for large forms read on submit • react-hook-form + Zod for the common case

Expert tier

• Field-level subscriptions to avoid form-wide re-renders • Server-driven validation pulled into the same Zod schema • Use FormData + progressive enhancement for SSR-first forms

Lifting state up, prop drilling, and Context — choose wisely

When two siblings need the same state, lift state up to their nearest common ancestor and pass it down as props. This is a useful default when both siblings genuinely share the same source of truth.

When the ancestor is many levels above and intermediate components don't care, you have prop drilling. The reflex to reach for Context here is often wrong. The better question: does the state really belong at the top, or should the deeply-nested consumer own its own state, or should the prop ride on children composition (slots) instead?

Reach for Context only when a value is genuinely needed by many leaves, when its identity is stable, and when its update frequency is low (theme, current user, route info). For frequently-changing tree-wide state (live cursor positions, real-time data, deeply-nested form state), reach for an external store — Zustand, Jotai, or Redux Toolkit — which provides fine-grained subscriptions Context lacks.

Good enough to ship

• Lift state up to nearest common ancestor • Context for theme/user/locale • Composition (children/slots) to avoid drilling

Expert tier

• Reach for external stores for high-frequency global state • Audit re-renders before adding Context • Use compound-component patterns for shared local state

Custom hooks

A custom hook is just a function whose name starts with use and which calls other hooks. They encapsulate reusable stateful logic — pagination, form state, subscriptions to a third-party store, mouse position, debounced values. The naming convention (use*) is what tells the lint rule it must follow hooks rules.

Good custom hooks have one job, return a stable shape (an object or tuple), and accept the inputs they need as parameters (not via closure). Bad custom hooks try to do too much, hide side effects unexpectedly, or return ad-hoc shapes that change between renders.

function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}
 
// usage
const q = useDebouncedValue(searchInput, 250);
useEffect(() => { search(q); }, [q, search]); // search must own cancellation/error handling

Good enough to ship

• Name custom hooks use* • One job per hook • Inputs as args, not closures

Expert tier

• Test hooks in isolation with @testing-library/react • Return stable references (memoised objects) • Compose hooks via other hooks for layered abstractions

Wrapping imperative libraries with refs and effects

Most JavaScript libraries (Highcharts, Monaco editor, Mapbox, D3) are imperative — you construct an instance, attach it to a DOM node, mutate it via methods. To use them in React, wrap them in a component that owns the lifecycle: create on mount (effect), update on prop change (separate effect), destroy on unmount (cleanup).

The pattern is the same every time:

function HighchartsWrapper({ options }: { options: Highcharts.Options }) {
  const container = useRef<HTMLDivElement>(null);
  const chart = useRef<Highcharts.Chart | null>(null);
 
  useEffect(() => {
    if (!container.current) return;
    chart.current = Highcharts.chart(container.current, { series: [] });
    return () => { chart.current?.destroy(); chart.current = null; };
  }, []); // lifecycle has no reactive options dependency
 
  useEffect(() => {
    chart.current?.update(options, true, true);
  }, [options]); // update on options change
 
  return <div ref={container} style={{ width: '100%', height: 400 }} />;
}
  • The lifecycle starts from an empty chart, so it does not capture options behind a dishonest empty dependency list. The following effect applies all reactive options.
  • The ref for the chart instance avoids triggering re-renders when the instance changes.
  • Pass memoised options from the parent or you re-update every render unnecessarily.
  • Cleanup destroys the chart so it doesn't leak listeners and DOM.

Good enough to ship

• Wrap imperative libs with refs + effects • Lifecycle effect + update effect • Cleanup destroys the instance

Expert tier

• Defer expensive options diffing with shallow comparators • Use ResizeObserver for chart auto-resize • Wrap third-party event handlers with refs to avoid stale closures

Suspense and lazy loading (awareness)

React.lazy(() => import('./Heavy')) returns a component that loads its module on first render. Wrap it in <Suspense fallback={<Spinner />}> and React shows the fallback until the chunk arrives. This is the bread-and-butter code-splitting pattern.

Suspense is broader than lazy components — in React 18+, it integrates with data libraries (TanStack Query, Relay) to show fallbacks while data loads. A Suspense-enabled read or lazy component can activate the nearest boundary; fetching in an ordinary effect does not. On a later suspension, a transition or deferred value may preserve already-visible content rather than immediately replacing it with the fallback.

const Dashboard = lazy(() => import('./Dashboard'));
 
<Suspense fallback={<Spinner />}>
  <Dashboard />
</Suspense>

Good enough to ship

• Lazy-load heavy routes with React.lazy + Suspense • Provide a fallback spinner or skeleton • One Suspense per logical loading region

Expert tier

• Use Suspense with data libraries for waterfall avoidance • Stream Suspense boundaries with RSC in Next.js • Tune Suspense + ErrorBoundary placement for resilience

Error boundaries

An error boundary normally uses getDerivedStateFromError to select fallback state and componentDidCatch for reporting. It catches descendant rendering and lifecycle errors, including synchronous effect failures during commit, but not arbitrary later promise rejections. It does not catch errors in event handlers, async code, or itself.

Put error boundaries at meaningful tree boundaries: per-route, per-major-section, per-third-party widget. Don't wrap every component; the whole app inside one boundary is the wrong granularity (you can't recover any single panel without losing all). Use the react-error-boundary library to get a hooks-friendly API plus reset semantics.

import { ErrorBoundary } from 'react-error-boundary';
 
<ErrorBoundary
  fallbackRender={({ error, resetErrorBoundary }) => (
    <div role="alert">
      <p>Something broke.</p>
      <p>Retry the panel or contact support with the incident identifier.</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  )}
  onError={(e) => reportSanitizedError(e)} // application-owned, reviewed telemetry boundary
>
  <Dashboard />
</ErrorBoundary>

Good enough to ship

• One boundary per route or major section • Log errors to a monitor • Provide a retry/reset action

Expert tier

• Boundary per third-party widget so one widget can't crash the app • Combine with Suspense for unified loading + error UX • Catch async errors via promise-rejection handlers and rethrow into a boundary-aware state

Worked examples

Example 1 — typed data-fetching hook with abort

function useFetch<T>(url: string, parse: (raw: unknown) => T): { data: T | null; error: Error | null; loading: boolean } {
  const [state, setState] = useState<{ data: T | null; error: Error | null; loading: boolean }>({
    data: null, error: null, loading: true,
  });
 
  useEffect(() => {
    const c = new AbortController();
    let active = true;
    setState({ data: null, error: null, loading: true });
    fetch(url, { signal: c.signal })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(raw => parse(raw))
      .then(data => { if (active) setState({ data, error: null, loading: false }); })
      .catch(reason => {
        if (!active) return;
        const error = reason instanceof Error ? reason : new Error('Request failed');
        setState({ data: null, error, loading: false });
      });
    return () => { active = false; c.abort(); };
  }, [url, parse]);
 
  return state;
}
  • AbortController cleans up when url changes or component unmounts.
  • The parser validates unknown data and must have a stable identity (for example a module-level function). A new inline parser every render retriggers this effect. Loading clears the prior error/data; the effect commit still occurs after rendering.
  • The cleanup is what makes Strict Mode double-mount survive without leaks.
  • For real apps, prefer TanStack Query (chapter 10) — this is the from-scratch version.

Example 2 — controlled form with field-level validation

function SignupForm() {
  const emailId = useId();
  const [email, setEmail] = useState('');
  const [touched, setTouched] = useState(false);
  const validationError = !email ? 'Email is required'
    : !email.includes('@') ? 'Email looks wrong' : null;
  const error = touched ? validationError : null;
 
  return (
    <form noValidate onSubmit={(e) => { e.preventDefault(); setTouched(true); if (validationError) return; /* send to server, which validates again */ }}>
      <label htmlFor={emailId}>Email</label>
      <input
        id={emailId}
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={() => setTouched(true)}
        aria-invalid={!!error}
        aria-describedby={error ? `${emailId}-error` : undefined}
      />
      {error && <p id={`${emailId}-error`} role="alert">{error}</p>}
      <button type="submit" >Sign up</button>
    </form>
  );
}
  • Validation is a cheap render-time expression; no memo or effect is needed. noValidate deliberately disables native submit blocking so this example owns feedback even on submit-before-blur. Keep type="email" for input semantics; a production form needs a real validation policy.
  • touched defers displayed errors until blur or the first submit attempt; it does not control whether submission is valid.
  • aria-invalid + aria-describedby tie the error to the input for screen readers.
  • The includes-at-sign check demonstrates feedback timing, not a complete email-validation policy. Keep submit reachable, validate again server-side, and choose a form library based on complexity rather than field count.

Example 3 — Highcharts wrapper component

See "Wrapping imperative libraries" above. The full file:

import { useEffect, useRef } from 'react';
import Highcharts from 'highcharts';
 
export function Chart({ options }: { options: Highcharts.Options }) {
  const container = useRef<HTMLDivElement>(null);
  const chart = useRef<Highcharts.Chart | null>(null);
 
  useEffect(() => {
    if (!container.current) return;
    chart.current = Highcharts.chart(container.current, { series: [] });
    return () => { chart.current?.destroy(); chart.current = null; };
  }, []);
 
  useEffect(() => {
    chart.current?.update(options, true, true);
  }, [options]);
 
  return <div ref={container} style={{ width: '100%', height: 400 }} />;
}
  • Targets the Highcharts chart/update/destroy API (12.x teaching baseline). Highcharts may retain/mutate options data; pass fresh owned data or a deliberate copy when immutable React state is the source. Stable series IDs make one-to-one updates less surprising.
  • Parent memoises options (or the wrapper updates excessively).
  • Cleanup destroys the chart on unmount.
  • Pair with a ResizeObserver for responsive resizing in Chapter 11.

Example 4 — Context with split value/setter for performance

const UserContext = createContext<User | null>(null);
const UserSetterContext = createContext<(u: User | null) => void>(() => {});
 
export function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  return (
    <UserSetterContext.Provider value={setUser}>
      <UserContext.Provider value={user}>{children}</UserContext.Provider>
    </UserSetterContext.Provider>
  );
}
 
export const useUser    = () => useContext(UserContext);
export const useSetUser = () => useContext(UserSetterContext);
  • Setter-only consumers are not notified by the value context; parent/state updates can still render them.
  • Read consumers are notified when the provided user value changes; this is not their only possible render cause.
  • The setter from useState is stable across renders — no memoisation needed.
  • Scales to a small number of slices; for larger global state, reach for Zustand.

Hands-on exercises

  1. Audit a friend's component for hook rule violations. Goal: build the muscle for spotting subtle bugs.

    1. Pick a real component file (in your repo or open source) with ≥3 hooks.
    2. Check: are all hooks unconditional? Do effect deps lists list everything used? Is cleanup present where needed?
    3. Fix any you find. Add react-hooks/exhaustive-deps lint if missing.
    4. You're done when: you can read a component file and instantly spot a missing cleanup or stale closure.
  2. Custom hook: useLocalStorage. Goal: write a hook that mirrors a state value to localStorage.

    1. Signature: useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void].
    2. Read initial value lazily from localStorage on mount.
    3. Write to localStorage on every change.
    4. Bonus: sync across tabs via the storage event.
    5. You're done when: refreshing the page restores the state and a second tab updates live.
  3. Render counter. Goal: feel the React rendering model in your fingers.

    1. Add a console.log('render Foo') to three components.
    2. Hold state in the topmost; mutate it; observe which descendants re-render.
    3. Move state down to a leaf — observe the difference.
    4. Wrap one descendant in React.memo and confirm it stops re-rendering when its props don't change.
    5. You're done when: you can predict which logs fire on any state change without running it.
  4. Wrap an imperative library. Goal: integrate Monaco editor (or any imperative widget) cleanly.

    1. Create a <CodeEditor value onChange language /> component.
    2. Initialise the editor with non-reactive defaults; if construction truly depends on reactive props, include them and deliberately recreate, or separate initial-only and update contracts.
    3. Update the value via an effect when props change.
    4. Dispose the editor in cleanup.
    5. You're done when: repeated development setup/cleanup and 100 mount/unmount cycles balance owned instances, listeners and timers; retained-heap profiling is a separate check, not proven by those counts alone.
  5. Error boundary + retry. Goal: contain a failing widget without crashing the page.

    1. Install react-error-boundary.
    2. Wrap a flaky third-party component (or a deliberately-throwing one).
    3. Provide a fallback with a "Retry" button that calls resetErrorBoundary.
    4. Log the error to console (or your monitor of choice).
    5. You're done when: throwing inside the widget shows the fallback, retry reloads it, and the rest of the page is untouched.
  6. Form with react-hook-form + Zod. Goal: end-to-end type-safe form.

    1. Build a 4-field signup form (name, email, password, confirmPassword).
    2. Define a Zod schema with cross-field validation (confirmPassword === password).
    3. Use react-hook-form + zodResolver.
    4. Display field-level errors with proper ARIA wiring.
    5. You're done when: the form fully validates client-side, types flow from schema to handler, and a screen reader announces errors.
  7. Replace a useEffect-fetch with TanStack Query. Goal: see how query libraries change what effects you write.

    1. Pick a component that does fetch in useEffect.
    2. Replace with useQuery({ queryKey: [...], queryFn: ... }).
    3. Move pagination, retries, and refetch policies to the query options.
    4. Measure requests for your staleTime, cache and cancellation settings. A query library does not promise exactly one request under every Strict Mode or remount scenario.
    5. You're done when: the query key isolates distinct data, stale responses cannot overwrite the active view, and refetch/retry behavior matches the chosen policy.
  8. Profile and remove a wasted memo. Goal: prove that a useMemo/useCallback is not pulling weight, then delete it.

    1. Open React DevTools Profiler.
    2. Identify a useMemo/useCallback whose enclosing component re-renders rarely or whose downstream isn't a memo child.
    3. Remove it in a comparison branch and re-record the same workload. Keep the removal only if the measurement and hook-dependency behavior justify it.
    4. Write a comment in the PR explaining what made the memo dead weight.
    5. You're done when: you can spot a wasted memo in a code review without running the profiler.

Self-check questions

  1. What does JSX actually compile to?
  2. Explain why React calls component functions multiple times per state change.
  3. When is index a safe key, and when is it dangerous?
  4. Why must you list every external value in useEffect deps? What goes wrong if you don't?
  5. What does Strict Mode in dev do to effects, and why?
  6. Difference between useMemo for cost vs for referential stability.
  7. When would you reach for useReducer over useState?
  8. Why does a Context with a fresh object value re-render every consumer every render?
  9. Difference between useEffect and useLayoutEffect. When do you actually need the latter?
  10. What problem does useId solve that random IDs cannot?
  11. What does an Error Boundary not catch?
  12. Why are uncontrolled inputs sometimes preferable to controlled ones?
  13. When you wrap an imperative library, why is the two-effect pattern (lifecycle + update) standard?
  14. What is a stale closure inside an effect, and how do you fix one?
  15. What is React.lazy good for, and what does it require around it?

Worked assessment answers

A/B race: start A, change URL to B, settle B, then A. Cleanup makes A inactive before B owns the next setup, so A cannot commit even if the fake transport ignores abort. Repeat with unmount before settlement, HTTP 404, malformed payload and a parser that throws. Reset old error/data when loading a new request. Abort is resource cleanup; the active flag is the state-ownership check.

Widget lifecycle: an empty-dependency effect must not quietly capture changing options. Construct with non-reactive defaults, update with [options], and destroy exactly the instance it owns. Development setup/cleanup/setup should leave one live instance; final unmount should leave zero. Count create/destroy calls, not just visible charts.

Storage-hook exercise: choose a client-only or hydration-safe initial state; guard storage access/JSON parsing, validate the value with a parser, decide what happens when key changes, and listen for cross-tab storage events. The event does not fire in the same document that performed the write. Do not store credentials in this exercise.

Self-check key: (1) JSX produces element descriptions via the JSX runtime; (2) render work may repeat/discard before commit, so render must be pure; (3) index keys are tolerable only for lists whose identity/order truly cannot change; (4) missing reactive dependencies preserve stale closures; (5) Strict Mode adds development checks, not a production double-mount contract; (6) memo can avoid cost or stabilize identity but may be discarded; (7) reducers express related transitions; (8) context consumers are notified when value changes by Object.is; (9) layout effects block paint, ordinary effects synchronize after commit and are not guaranteed always after paint; (10) useId coordinates accessible IDs across matching server/client trees, not list keys; (11) boundaries miss event-handler errors, ordinary async rejections and their own failures; (12) uncontrolled fields let the DOM hold values; (13) lifecycle and reactive updates often have different dependencies; (14) a stale closure reads a prior render snapshot; (15) lazy needs a default component export and Suspense for loading, plus an error boundary for rejected imports.

Form exercise: give every field a stable label and error association, test submit before blur, and do not confuse an eight-character UI password rule with authentication policy. Error-boundary retry resets boundary state, not necessarily a rejected lazy-module cache or a failing backend.

High-signal resources

Official docs

Books or courses

  • Learning React by Alex Banks & Eve Porcello — use the publisher's edition listing; check older examples against the React 19.2 baseline here.
  • Epic React by Kent C. Dodds (paid, opinionated, very high signal).
  • The Joy of React by Josh Comeau.

Practitioner posts

Weekly milestones

  1. Day 1 — Read react.dev "Describing the UI" and "Adding Interactivity". Do Exercise 3.
  2. Day 2 — Read "Managing State" + "Escape Hatches". Do Exercise 2.
  3. Day 3 — Read Dan Abramov's useEffect guide. Do Exercise 1.
  4. Day 4–5 — Do Exercises 4 and 5. Answer self-check 1-8.
  5. Day 6–7 — Do Exercise 6. Watch one Epic React video on rendering. Answer self-check 9-15.

How it shows up in the capstone

Every UI surface in the capstone is React: the app shell, the dashboard grid, the filter sidebar, the chart cards, the modals. Routing is React Router (chapter 10); data fetching is TanStack Query; charts are wrapped imperative Highcharts instances (chapter 11). The component decomposition follows the rules in this chapter: state lives at the leaf where possible, lifts to a parent when two siblings need it, and only escapes to Context for theme and current user.

Effects are disciplined — one concern each, every reactive value used by an effect in its deps, cleanup on every subscription. Imperative wrappers (Highcharts, the date picker, the export-CSV widget) all use the two-effect lifecycle/update pattern. Each route is wrapped in an <ErrorBoundary> so a chart crash doesn't blank the page, and <Suspense> boundaries provide skeleton fallbacks while lazy chunks load.

You will feel this chapter most the first time you debug an effect that ran when it shouldn't have — and the answer turns out to be one stale closure away. After three months of disciplined React, the rendering model becomes a sixth sense: you predict what runs in what order without thinking, and the bugs that used to take a day take ten minutes.

Next chapter → Ch 10 — Routing and state in SPAs Previous chapter → Ch 8 — Frontend build tooling

Corrected contracts and failure analysis

An effect synchronizes with an external system and must undo its own setup. Strict Mode's extra development setup/cleanup cycle exposes missing cleanup; suppressing it with a hasRun ref hides the defect. Abort stale requests and guard state updates from obsolete completions, especially if a parser or mocked transport ignores abort. A generic JSON cast still does not validate data.

The form must validate on submit even before blur; touched only controls when feedback is displayed. Client validation is not authorization. Keys identify sibling instances across reconciliation and should use stable data identities, not fresh random values. Memoization is an optimization, not a guarantee against rendering. A component may render because of its own state or parent even when context values remain stable. Imperative chart wrappers must destroy listeners and instances and must decide whether options are initial-only or reactive.

Boundary exercise with solution

URL A resolves after URL B. Which request owns state, and how does unmount affect it?

Solution and reasoning

Only the currently active effect may commit. Cleanup sets an inactive flag and aborts its controller; completion checks that flag. Then test A/B reverse order, unmount-before-resolution and a rejected HTTP response. Strict Mode is an additional stress test, not the whole suite.

Source-backed review notes

  • useEffect – React — accessed 2026-10-07. Exact supporting passage: “When Strict Mode is on, React will run one extra development-only setup+cleanup cycle before the first real setup. This is a stress-test that ensures that your cleanup logic “mirrors” your setup logic and that it stops or undoes whatever the setup is doing. If this causes a problem, implement the cleanup function.”
  • TypeScript: Documentation - Everyday Types — accessed 2026-10-07. Exact supporting passage: “Like a type annotation, type assertions are removed by the compiler and won’t affect the runtime behavior of your code.”

Version-specific reference contracts

Documentation snapshots accessed 7 October 2026; mutable service/browser documentation is timestamped evidence, not an immutable product-version pin. Exact supporting passages and hashes accompany the review ledger.

Review and execution boundary

React 19.2.4 client-component fixtures; development Strict Mode behavior, not a claim that production always mounts twice.

Fresh whole-current review on 9 October 2026, reusing the hash-verified 7 October original reading and coverage audit. Exact fetching and chart callbacks ran with controlled hooks/transport/widget mocks, including stale-response and cleanup negative cases; those tests do not execute the React scheduler or Highcharts. React 19.2.4 server-rendered the exact signup example, and an offline Chromium fixture checked native submit blocking before/after noValidate; there was no hydration or full-app browser test. Optional react-hook-form, Highcharts and telemetry integrations are not represented as installed or deployed here.

Fresh primary references for the corrections: state updaters, useCallback use cases and cache limits, effect dependencies, Suspense boundaries, matching trees for useId, and form novalidate. These fresh mutable docs support the cited contracts, not later APIs outside the React 19.2 baseline. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.