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.
Chapter 9 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
React is the most common way to build a non-trivial frontend in 2026, full stop. It is not the best at everything — Solid is faster, Svelte is smaller, Vue has nicer ergonomics in places — but the ecosystem, the hiring market, the library support, and the integration story for design systems and observability platforms all converge on React. Microsoft's internal tooling, including most of Office and OneNote's web surfaces, is React. Learn React deeply and you are immediately employable across most of the industry.
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 is for computational cost not for referential stability, 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.
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 /> evaluates to an object like { type: Button, props: { label: 'Save' }, key: null, ref: null }. 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 at module top-level) breaks the first time React does a double-render in dev mode or a concurrent re-render in prod.
- • Write function components
- • Render is a pure function of props + state
- • No side effects during render
- • 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>
- • Type props with TypeScript
- • Use children for composition
- • Reach for fragments instead of wrapper divs
- • 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 minimum set of DOM mutations 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) wraps a component so it only re-renders if its props change (shallow equality). 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} />)}
- • Always provide stable keys for lists
- • Never sprinkle memo/useMemo until profiling
- • Move state down to localise re-renders
- • 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)). Use the function form whenever the new state depends on the old — it's the only way to be correct under batched / concurrent updates.
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())) computes the default only on the first render. 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>
);
}
- • Use the function form when next depends on prev
- • Lazy init for expensive defaults
- • Never store derived values in state
- • Know automatic batching across async boundaries (React 18+)
- • Use useReducer when state has interrelated fields
- • Reach for
flushSynconly when you must read DOM mid-handler
useEffect — dependencies, cleanup, the rules
useEffect(fn, deps) runs fn after every render where any value in deps changed (by Object.is). With [], it runs once after mount and the cleanup runs at unmount. With no deps array, it runs after every render — almost always a bug.
Two rules you must internalise:
- List every value used inside the effect in the deps array. This is what
react-hooks/exhaustive-depsenforces. 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. - 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();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(e => { if (e.name !== 'AbortError') throw e; });
return () => controller.abort();
}, [id]);
- • List every external value in deps
- • Always return a cleanup if you set anything up
- • One effect, one concern
- • 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 the return value of fn and recomputes only when deps change. useCallback(fn, deps) is useMemo for functions — it returns the same function reference across renders unless deps change. 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 19+ ships React Compiler, which auto-memoises components based on dependency analysis. 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} />
- • Don't reach for memo until profiling
- • Use useMemo for expensive computation
- • Use useCallback only when passing to a memo child
- • 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 a stable unique ID across server and client renders — 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() {
const dialog = useRef<HTMLDivElement>(null);
const titleId = useId();
useLayoutEffect(() => {
dialog.current?.focus(); // before paint, no flicker
}, []);
return (
<div ref={dialog} role="dialog" aria-labelledby={titleId} tabIndex={-1}>
<h2 id={titleId}>Confirm</h2>
</div>
);
}
- • useRef for DOM access and mutable non-render values
- • useId for accessible label/control pairings
- • useLayoutEffect only for pre-paint DOM measure/mutate
- • Forward refs through wrapper components with forwardRef
- • 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 state has more than three fields or when one action updates several of them at once.
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, action] };
case 'remove': return { ...state, items: state.items.filter(i => i.id !== action.id) };
case 'clear': return { ...state, items: [] };
}
}
const [cart, dispatch] = useReducer(cartReducer, { items: [] });
dispatch({ type: 'add', id: 'X1', qty: 1 });
- • Context for theme, user, locale
- • Memoise context values
- • useReducer for related-field state
- • 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
Hooks must be called unconditionally and in the same order on every render. 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.
The two rules from the docs:
- Only call hooks at the top level — not inside conditionals, loops, or nested functions.
- 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 }) {
if (enabled) {
const [x, setX] = useState(0); // ❌ conditional hook
}
}
// Good
function Comp({ enabled }) {
return enabled ? <Inner /> : null;
}
function Inner() {
const [x, setX] = useState(0); // ✅ always called
}
- • Top-level hooks only
- • ESLint rules-of-hooks on
- • Extract components instead of conditional hooks
- • 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 { register, handleSubmit, formState } = useForm<FormValues>({
resolver: zodResolver(Schema),
});
return (
<form onSubmit={handleSubmit(onLogin)}>
<input {...register('email')} aria-invalid={!!formState.errors.email} />
{formState.errors.email && <p>{formState.errors.email.message}</p>}
<input type="password" {...register('password')} />
<button disabled={formState.isSubmitting}>Sign in</button>
</form>
);
}
- • Controlled for small forms with live behaviour
- • Uncontrolled for large forms read on submit
- • react-hook-form + Zod for the common case
- • 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 React 101 and the right answer 80% of the time.
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.
- • Lift state up to nearest common ancestor
- • Context for theme/user/locale
- • Composition (children/slots) to avoid drilling
- • 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]);
- • Name custom hooks
use* - • One job per hook
- • Inputs as args, not closures
- • 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, options);
return () => { chart.current?.destroy(); chart.current = null; };
}, []); // create/destroy once
useEffect(() => {
chart.current?.update(options, true, true);
}, [options]); // update on options change
return <div ref={container} style={{ width: '100%', height: 400 }} />;
}
- Two effects: one for lifecycle (
[]deps), one for updates ([options]). - The
reffor 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.
- • Wrap imperative libs with refs + effects
- • Lifecycle effect + update effect
- • Cleanup destroys the instance
- • 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. The mental model: a Suspense boundary catches any suspended descendant and renders the fallback in its place.
const Dashboard = lazy(() => import('./Dashboard'));
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
- • Lazy-load heavy routes with React.lazy + Suspense
- • Provide a fallback spinner or skeleton
- • One Suspense per logical loading region
- • 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 is a class component with a componentDidCatch (or getDerivedStateFromError) method. It catches render-phase errors in its subtree and renders a fallback UI instead of crashing the whole app. 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>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
onError={(e) => sentry.captureException(e)}
>
<Dashboard />
</ErrorBoundary>
- • One boundary per route or major section
- • Log errors to a monitor
- • Provide a retry/reset action
- • 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): { 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();
setState(s => ({ ...s, loading: true }));
fetch(url, { signal: c.signal })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<T>; })
.then(data => setState({ data, error: null, loading: false }))
.catch(error => {
if (error.name === 'AbortError') return;
setState({ data: null, error, loading: false });
});
return () => c.abort();
}, [url]);
return state;
}
- AbortController cleans up when url changes or component unmounts.
- Single state object so the three values update atomically.
- 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 [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);
const error = useMemo(() => {
if (!touched) return null;
if (!email) return 'Email is required';
if (!email.includes('@')) return 'Email looks wrong';
return null;
}, [email, touched]);
return (
<form onSubmit={(e) => { e.preventDefault(); console.log({ email }); }}>
<label htmlFor="email">Email</label>
<input
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && <p id="email-error" role="alert">{error}</p>}
<button type="submit" disabled={!!error}>Sign up</button>
</form>
);
}
- Validation runs in render via
useMemo— no extra effect needed. toucheddefers errors until the user blurs the field.aria-invalid+aria-describedbytie the error to the input for screen readers.- For more than two fields, switch to react-hook-form.
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, options);
return () => { chart.current?.destroy(); chart.current = null; };
}, []);
useEffect(() => {
chart.current?.update(options, true, true);
}, [options]);
return <div ref={container} style={{ width: '100%', height: 400 }} />;
}
- Reusable for any Highcharts config.
- Parent memoises options (or the wrapper updates excessively).
- Cleanup destroys the chart on unmount.
- Pair with a
ResizeObserverfor 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);
- Components that only need to set don't re-render when
userchanges. - Components that only read re-render only when
useractually changes. - The setter from
useStateis stable across renders — no memoisation needed. - Scales to a small number of slices; for larger global state, reach for Zustand.
Hands-on exercises
-
Audit a friend's component for hook rule violations. Goal: build the muscle for spotting subtle bugs.
- Pick a real component file (in your repo or open source) with ≥3 hooks.
- Check: are all hooks unconditional? Do effect deps lists list everything used? Is cleanup present where needed?
- Fix any you find. Add
react-hooks/exhaustive-depslint if missing. - You're done when: you can read a component file and instantly spot a missing cleanup or stale closure.
-
Custom hook: useLocalStorage. Goal: write a hook that mirrors a state value to localStorage.
- Signature:
useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void]. - Read initial value lazily from localStorage on mount.
- Write to localStorage on every change.
- Bonus: sync across tabs via the
storageevent. - You're done when: refreshing the page restores the state and a second tab updates live.
- Signature:
-
Render counter. Goal: feel the React rendering model in your fingers.
- Add a
console.log('render Foo')to three components. - Hold state in the topmost; mutate it; observe which descendants re-render.
- Move state down to a leaf — observe the difference.
- Wrap one descendant in
React.memoand confirm it stops re-rendering when its props don't change. - You're done when: you can predict which logs fire on any state change without running it.
- Add a
-
Wrap an imperative library. Goal: integrate Monaco editor (or any imperative widget) cleanly.
- Create a
<CodeEditor value onChange language />component. - Initialise the editor in a
useEffectwith[]deps. - Update the value via an effect when props change.
- Dispose the editor in cleanup.
- You're done when: mounting/unmounting the editor 100 times in dev StrictMode doesn't leak memory.
- Create a
-
Error boundary + retry. Goal: contain a failing widget without crashing the page.
- Install
react-error-boundary. - Wrap a flaky third-party component (or a deliberately-throwing one).
- Provide a fallback with a "Retry" button that calls
resetErrorBoundary. - Log the error to console (or your monitor of choice).
- You're done when: throwing inside the widget shows the fallback, retry reloads it, and the rest of the page is untouched.
- Install
-
Form with react-hook-form + Zod. Goal: end-to-end type-safe form.
- Build a 4-field signup form (name, email, password, confirmPassword).
- Define a Zod schema with cross-field validation (
confirmPassword === password). - Use react-hook-form + zodResolver.
- Display field-level errors with proper ARIA wiring.
- You're done when: the form fully validates client-side, types flow from schema to handler, and a screen reader announces errors.
-
Replace a useEffect-fetch with TanStack Query. Goal: see how query libraries change what effects you write.
- Pick a component that does
fetchinuseEffect. - Replace with
useQuery({ queryKey: [...], queryFn: ... }). - Move pagination, retries, and refetch policies to the query options.
- Confirm
Strict Modedouble-render no longer issues two real network calls. - You're done when: the component is fewer lines, the network panel is quieter, and caching is automatic.
- Pick a component that does
-
Profile and remove a wasted memo. Goal: prove that a
useMemo/useCallbackis not pulling weight, then delete it.- Open React DevTools Profiler.
- Identify a
useMemo/useCallbackwhose enclosing component re-renders rarely or whose downstream isn't a memo child. - Delete it; re-record; observe no perf delta.
- Write a comment in the PR explaining what made the memo dead weight.
- You're done when: you can spot a wasted memo in a code review without running the profiler.
Self-check questions
- What does JSX actually compile to?
- Explain why React calls component functions multiple times per state change.
- When is
indexa safekey, and when is it dangerous? - Why must you list every external value in
useEffectdeps? What goes wrong if you don't? - What does Strict Mode in dev do to effects, and why?
- Difference between
useMemofor cost vs for referential stability. - When would you reach for
useReduceroveruseState? - Why does a Context with a fresh object value re-render every consumer every render?
- Difference between
useEffectanduseLayoutEffect. When do you actually need the latter? - What problem does
useIdsolve that random IDs cannot? - What does an Error Boundary not catch?
- Why are uncontrolled inputs sometimes preferable to controlled ones?
- When you wrap an imperative library, why is the two-effect pattern (lifecycle + update) standard?
- What is a stale closure inside an effect, and how do you fix one?
- What is
React.lazygood for, and what does it require around it?
High-signal resources
Official docs
- react.dev — the new docs, cover-to-cover. Especially "Thinking in React" and "Escape Hatches".
- React DevTools Profiler guide.
- WAI-ARIA Authoring Practices — when building custom widgets.
Books or courses
- Learning React by Alex Banks & Eve Porcello (3rd ed., O'Reilly, 2024).
- Epic React by Kent C. Dodds (paid, opinionated, very high signal).
- The Joy of React by Josh Comeau.
Practitioner posts
- Dan Abramov — A Complete Guide to useEffect — the canonical effect reference.
- Dan Abramov — Before You memo().
- Mark Erikson — A (Mostly) Complete Guide to React Rendering Behavior.
- Kent C. Dodds — When to useMemo and useCallback.
- TkDodo — Practical React Query — gives the right shape for chapter 10.
Weekly milestones
- Day 1 — Read react.dev "Describing the UI" and "Adding Interactivity". Do Exercise 3.
- Day 2 — Read "Managing State" + "Escape Hatches". Do Exercise 2.
- Day 3 — Read Dan Abramov's useEffect guide. Do Exercise 1.
- Day 4–5 — Do Exercises 4 and 5. Answer self-check 1-8.
- 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 external value in 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