Chapter 11 · Frontend

React and the Frontend Framework Landscape

Chapter 9 built the API. This chapter builds the thing that calls it — and answers the question every one of those chapters left open: why React, out of a genuinely competitive field, and what does it actually buy you once you're rendering a chat UI where tokens are arriving over the SSE connection chapter 9 designed.

24 sections React 19 Streaming AI chat UI 10 interview drills Reading time ~2.5 hours

[!] A note on what's verified in this chapter, and what isn't

Every prior chapter in this course pulled real package versions off the Walmart artifactory PyPI mirror before writing about them. That mirror only reaches the Python package index — there is no equivalent npm registry access in this environment, confirmed by testing both a Walmart npm mirror and the public npm registry directly, neither of which was reachable.

Everything in this chapter about React, Vue, Svelte, Angular, and Solid therefore reflects general, widely known facts about those projects and their current stable lines as of general knowledge, not a version-pinned package inspection. Treat specific version numbers as approximate, and verify them with npm view react version or the project's own release notes before quoting one in an interview or a design doc.

11.1 What a framework is actually for

Before comparing React to anything, it's worth being precise about the problem every frontend framework in this chapter exists to solve, because the comparison in 11.2 only makes sense once the problem is named.

[def] The problem: keeping the screen truthful as data changes

A web page is a tree of DOM nodes. The moment your application has state that changes — a new chat message arrives, a user toggles a setting, a list item is deleted — something has to update the right nodes, in the right order, without touching the rest of the tree. Doing that by hand, with direct DOM calls scattered through event handlers, is exactly how large applications before 2010 became unmaintainable: the DOM state and the application's actual state drift out of sync, and nobody can say with confidence what a given piece of UI currently shows without reading every place that might have touched it.

Imperative: tell the DOM what to do

const li = document.createElement("li");
li.textContent = message.text;
list.appendChild(li);
// ...and when the message is deleted, some
// other piece of code has to find and remove
// this exact node, correctly, every time.

Declarative: describe what the screen should show

function MessageList({ messages }) {
  return (
    <ul>
      {messages.map(m => <li key={m.id}>{m.text}</li>)}
    </ul>
  );
}
// Delete a message from the messages array, and
// the framework figures out which DOM node that was.

[+] Every framework in this chapter makes the same trade

You stop writing "how to update the DOM" and start writing "what the UI looks like for a given state," and the framework's job is closing the gap between the two, correctly, on every change. React, Vue, Svelte, Angular, and Solid all make this exact trade — they disagree about how to close that gap efficiently, which is precisely what 11.2's comparison is actually about, not which one is "better" in the abstract.

[retail] This is chapter 9's translation layer, one level up

Chapter 9 framed the API as a translation layer between HTTP and whatever a backend system actually speaks. A frontend framework is a translation layer in the same sense, between "the state my application is in" and "the actual DOM nodes a browser renders." Neither translation is optional once an application is complex enough that a human can no longer track every place state and DOM might disagree.

11.2 The frontend framework landscape

React is one answer to 11.1's problem among several genuinely good ones. Seeing what the alternatives actually do differently is what makes "why React" in 11.3 an answer instead of a default.

Five frameworks solving the same problem differently
Framework Core mechanism Known for
React Virtual DOM diffing (11.14): re-run a component function, compare the result, patch the real DOM The largest ecosystem and hiring pool, and a component model other frameworks are frequently explained by comparison to.
Vue A reactivity system that tracks exactly which values a template reads, and updates only what depends on them Gentler learning curve, single-file components mixing template/script/style, strong official tooling out of the box.
Svelte A compiler: reactive code is transformed at build time into direct DOM-update instructions, no virtual DOM at runtime Small bundle sizes and less code to write for the same UI, since the framework's own runtime is mostly compiled away.
Angular A full framework: dependency injection, built-in routing, and forms, using TypeScript throughout Opinionated structure that scales across large teams, common in enterprise codebases already invested in TypeScript.
Solid Fine-grained reactivity like Vue's, but with a React-like component syntax and no virtual DOM Performance close to hand-written DOM updates, with an authoring experience deliberately close to React's.

[!] "Fine-grained reactivity" and "virtual DOM diffing" are the real technical split

React and Angular's change-detection model re-run a chunk of rendering logic and figure out afterward what changed. Vue, Svelte, and Solid instead track which exact piece of state a specific bit of DOM depends on, so an update to one value updates only that value's dependents directly, with no diffing step at all. This is the single technical idea underneath most of the performance and bundle-size differences in the "known for" column — not raw speed of the JavaScript engine, but how much work each framework does to figure out what needs updating in the first place.

[retail] There is no framework that "wins" the way vLLM won inference serving

Chapter 6 could point at raw throughput numbers to justify vLLM. Frontend frameworks don't have an equivalent single metric: React, Vue, Svelte, and Solid all build the same class of application, all with mature production track records at real scale — Meta on React, Alibaba and GitLab on Vue, this course's own upcoming choice of React notwithstanding. The decision genuinely does come down to team background, hiring pool, and ecosystem fit far more than a benchmark, which is exactly why 11.4 treats "when to choose something else" as a real question, not a formality.

11.3 Why React became the default

React did not win on technical superiority alone — Svelte's bundle sizes and Vue's learning curve are both genuinely better by the numbers in isolation. React won on a combination of timing, ecosystem gravity, and a component model that turned out to generalise unusually well.

[hist] 2013: released by Facebook, for a problem Facebook actually had

React shipped out of Facebook's own need to keep a newsfeed's UI consistent under very frequent updates — likes, comments, and new posts arriving continuously while a user scrolls. That's a harder version of 11.1's problem than most applications face, and React's answer to it (re-render a pure function of state, let a diffing algorithm figure out the minimal DOM patch) turned out to generalise to almost every other kind of interactive UI, not just social feeds.

[+] The component model outlasted the specific implementation

"A UI is a tree of components, each a pure function from props to markup" is the idea that actually spread — Vue and Solid both adopted variations of it after seeing React's version work. React's specific mechanics (the virtual DOM, hooks) have changed substantially since 2013; the component-tree mental model underneath has not, which is part of why skills learned on React transfer more easily to other frameworks than frameworks built on a fundamentally different mental model.

What actually compounds once a framework has a head start
Advantage Why it compounds
Ecosystem size More third-party components, more Stack Overflow answers, more battle-tested patterns for edge cases — each of which makes the next developer's choice easier, which grows the ecosystem further.
Hiring pool The larger the pool of developers who already know a framework, the lower the cost of hiring for it and onboarding into an existing codebase — a genuinely large factor in a technology-selection decision, not just a technical one.
Meta-framework layer Next.js built server-side rendering, routing, and API routes on top of React specifically, which pulled in an entire category of production apps that needed those features and had no equally mature Vue or Svelte equivalent for years.

[retail] This is the same "what's actually out there" trade-off as chapter 9's backend landscape

Chapter 9 made the same point about FastAPI versus Flask and Django: the underlying capability gap between competing frameworks is usually smaller than the ecosystem gap around them. React's technical answer to 11.1's problem is good, not uniquely possible — Vue and Solid answer it about as well by different means. What React actually offers a team today is the largest surrounding ecosystem to build on, which is a real, defensible reason to choose it that has nothing to do with which framework's rendering algorithm is cleverest.

11.4 When another framework is the better call

"React is popular" is not the same claim as "React is correct for this project." Three situations come up often enough to name explicitly.

[def] Three real reasons to choose something else

The team is new to frontend development entirely
Vue's single-file components and gentler mental model (no separate hooks rules to internalise, no JSX to learn alongside JavaScript) genuinely reduce time-to-productivity for developers without prior frontend experience — a real consideration for a small team without a dedicated frontend specialist.
Bundle size and load time are the dominant constraint
Svelte or Solid ship measurably less framework runtime code to the browser for equivalent UI, which matters disproportionately for a public-facing product on slow connections or low-end devices, less so for an internal enterprise tool everyone accesses on a fast corporate network.
The organisation is already a large TypeScript-first enterprise shop
Angular's batteries-included structure — dependency injection, a prescribed project layout, official routing and forms — reduces the number of architectural decisions a large team of otherwise-independent engineers has to converge on, which is a genuine advantage at organisational scale even though it costs more boilerplate per feature.

[!] "What does my team already know" usually outweighs every technical column in 11.2's table

A team of five React developers building a Vue app because Vue's benchmark numbers are marginally better will spend the first month re-learning fundamentals instead of shipping, a cost the benchmark numbers never priced in. The technical trade-offs in 11.2 are real, but for most teams building most applications, existing expertise is the deciding factor, and there is nothing unsophisticated about admitting that.

[+] Why the rest of this chapter is still React

Given the scenario chapter 9 designed for — a team building a React SPA against a FastAPI backend, needing a streaming AI chat interface — React remains the right default for exactly the ecosystem reasons in 11.3: mature streaming and chat-UI patterns already exist in the React ecosystem specifically, and the hooks-based mental model this chapter teaches from 11.9 onward is the one most transferable to whatever a reader's next project actually uses.

11.5 Components, JSX, and the declarative model

A React application is a tree of components, each one a function that takes some input and returns a description of what should appear on screen. Everything else in this chapter is a refinement of that one sentence.

A component: a function from input to markupjsx
function ConversationCard({ title, updatedAt }) {
  return (
    <div className="card">
      <h3>{title}</h3>
      <span className="timestamp">{updatedAt}</span>
    </div>
  );
}

[def] JSX is JavaScript, compiled, not a template language

That HTML-looking markup inside the function is JSX, and it compiles down to plain function calls — React.createElement("div", {...}, ...) underneath. This matters practically: because it's real JavaScript, you can use any expression inside curly braces — a ternary, a function call, a variable — with no separate templating syntax to learn, unlike Vue's or Angular's template directives.

[+] A component describes a state, not a sequence of mutations

ConversationCard never says "create a div, then set its text." It says "given this title and this timestamp, here is what the markup looks like" — and it says it fresh, every time React decides to call it again. This is 11.1's declarative trade made concrete: no code anywhere describes how to transition from the old markup to the new markup. React's diffing (11.14) does that part.

[!] A component name must start with a capital letter, and it isn't just a style rule

JSX uses capitalisation to decide whether a tag is a component or a plain HTML element: <card> is parsed as an actual DOM tag named "card" (which does nothing), while <Card> is parsed as a call to your Card component. This is a real, easy first mistake, not a stylistic preference the linter is nagging about.

11.6 Props and state: the two kinds of data

Every value a component uses is either handed to it from outside, or owned and managed by the component itself. Conflating the two is the single most common source of confusion for anyone new to React.

Props vs. state: where each one lives and who can change it
Props State
Owned by The parent component This component
Can this component change it? No — read-only from the component's own perspective Yes, via setState or a useState setter (11.9)
Changing it causes The parent to re-render, passing new props down This component (and its children) to re-render
Props flow down; state lives where it's ownedjsx
function ConversationList({ conversations }) {   // conversations: a prop
  const [selectedId, setSelectedId] = useState(null);   // selectedId: state

  return conversations.map(c => (
    <ConversationCard
      key={c.id}
      title={c.title}                 // passed down as a prop
      isSelected={c.id === selectedId}
      onSelect={() => setSelectedId(c.id)}
    />
  ));
}

[retail] "Where should this piece of state live" is a real design decision

selectedId lives in ConversationList, not in each individual ConversationCard, because selecting one conversation needs to affect all of them — deselecting whichever was previously selected. The general rule: state lives in the lowest common ancestor of every component that needs to read or change it. Getting this wrong in either direction — state too high, forcing unrelated components to re-render, or too low, unable to share it with a sibling — is a design smell worth noticing early.

11.7 Composition, conditional rendering, and lists

Three patterns cover most of what a component tree actually needs to express: building larger components from smaller ones, showing different markup depending on state, and rendering a variable number of items correctly.

[+] Composition over configuration

Rather than a single Card component with a dozen boolean props controlling every possible layout, React favours composing small components together: a Card that accepts children, wrapped around whatever content each call site actually needs. This is the same single-responsibility instinct chapter 9 applied to router modules, applied here to UI components instead of API endpoints.

Conditional rendering: three idioms, same ideajsx
{isLoading ? <Spinner /> : <MessageList messages={messages} />}

{error && <ErrorBanner message={error.message} />}

{status === "empty" ? <EmptyState />
  : status === "error" ? <ErrorBanner />
  : <MessageList messages={messages} />}

[!] A missing key on a list is not a cosmetic warning

key tells React which array item a given piece of rendered output corresponds to across re-renders, so it can match old DOM nodes to new data correctly instead of guessing by position. Using the array index as a key works only if the list never reorders or has items inserted in the middle; do either with an index key and React can attach the wrong component's internal state — a text input's typed value, a component's local useState — to the wrong row after a reorder. A stable, unique ID from the actual data is the correct key, not the array position.

11.8 Handling events the React way

React wraps native browser events in its own synthetic event system, which behaves consistently across browsers and integrates with the same re-render cycle everything else in this chapter relies on.

An event handler is just a function, passed as a propjsx
function SendButton({ onSend, disabled }) {
  return (
    <button onClick={onSend} disabled={disabled}>
      Send
    </button>
  );
}

// Usage: the parent decides what "send" actually does
<SendButton onSend={() => submitMessage(draft)} disabled={draft.trim() === ""} />

[def] Pass a function reference, not a function call

onClick={onSend} passes the function itself, to be called later when the click happens. onClick={onSend()} calls it immediately, during render, and passes whatever it returns as the handler — almost never what was intended, and a real, common typo for anyone coming from a language where that distinction is less immediately visible.

[retail] Every event handler that changes state is really just calling a setter

onSend above ultimately calls something that updates state — adding the message to a list, most likely via a useState setter covered next in Part C. The event system's whole job is triggering that state update at the right moment; React's rendering system then takes over and figures out what, if anything, needs to change on screen as a result.

11.9 useState and useEffect

Before 2019, state and lifecycle logic lived on class components, in methods like componentDidMount. Hooks let a plain function component hold state and run side effects, which is why nearly all React code written since has been function components plus hooks rather than classes.

useState: state that persists across re-rendersjsx
function MessageComposer() {
  const [draft, setDraft] = useState("");

  return (
    <textarea
      value={draft}
      onChange={e => setDraft(e.target.value)}
    />
  );
}

[def] Calling the setter schedules a re-render; it doesn't mutate anything immediately

setDraft("hi") doesn't change draft in place — it tells React "next time this component renders, use this new value," and schedules that render. Reading draft on the very next line after calling setDraft still sees the old value, which surprises almost everyone the first time they hit it, and is a direct consequence of state being immutable data React swaps in on the next render pass.

useEffect: synchronising with something outside Reactjsx
useEffect(() => {
  const id = setInterval(() => setElapsed(e => e + 1), 1000);
  return () => clearInterval(id);   // cleanup: runs before the next effect, and on unmount
}, []);   // empty array: run once, after the first render

[!] useEffect is for synchronising with the outside world, not a general "run this after render" hook

A timer, a subscription, a manual DOM measurement, fetching data from an API — all things React itself doesn't manage. Using useEffect to derive one piece of state from another (setting state inside an effect that watches different state) usually means the derived value should just be computed directly during render instead, which 11.11's useMemo is often the correct tool for, not an effect at all.

11.10 The dependency array, done correctly

The second argument to useEffect — and to useMemo and useCallback in 11.11 — is the single most consequential detail in this entire chapter to get right.

[def] What the array actually controls

React compares each value in the dependency array to its value from the previous render. If every value is the same, the effect is skipped; if any value differs, the effect re-runs. An empty array means "compare nothing, so never re-run after the first render." Omitting the array entirely means "always re-run, after every single render" — rarely what's actually wanted.

A dependency left out: a stale closure bug

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);   // always logs the FIRST count value
  }, 1000);
  return () => clearInterval(id);
}, []);   // count is used inside, but missing here

Declared honestly: correct, if noisier

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);   // sees the current count each time
  }, 1000);
  return () => clearInterval(id);
}, [count]);   // re-subscribes whenever count changes

[!] Silencing the exhaustive-deps lint rule almost never fixes the actual problem

The function inside useEffect captures the variables in scope at the time it was created — a "closure" over the state at that render. Leave a dependency out and the effect keeps using whatever value that variable had when the effect was first created, not its current value, which is exactly the bug in the left card above. Disabling the linter's exhaustive-deps warning silences the symptom without fixing the stale reference; the fix is almost always either adding the dependency honestly, or restructuring so the effect doesn't need it.

11.11 useMemo and useCallback: memoization, not magic

Both hooks do exactly one thing: skip redoing work if the inputs haven't changed since last time. Reaching for them without a measured reason is a common and usually pointless habit — 11.19 covers when they're actually worth it.

useMemo caches a value; useCallback caches a functionjsx
const sortedMessages = useMemo(
  () => [...messages].sort((a, b) => a.timestamp - b.timestamp),
  [messages]   // only re-sort when messages actually changes
);

const handleSend = useCallback(
  (text) => sendMessage(conversationId, text),
  [conversationId]   // only a new function if conversationId changes
);

[def] useCallback is useMemo for functions, nothing more

Every render of a component creates brand new function objects for anything defined inside it, even if the logic is identical — JavaScript functions are compared by reference, not by what they do. useCallback returns the same function reference across renders as long as its dependencies haven't changed, which matters specifically when that function reference is itself a dependency somewhere else, most commonly a prop passed to a memoized child (11.19).

[!] Memoizing something cheap costs more than it saves

Both hooks have their own overhead — storing the previous inputs, comparing them on every render. For a cheap computation (formatting a date, summing a ten-item array), that bookkeeping costs more than just redoing the work would have. The right instinct is default to plain computation, and reach for useMemo only for genuinely expensive work or to preserve a stable reference something else depends on — not as a reflexive performance habit applied everywhere.

11.12 useRef and useContext

Two hooks that solve two different, specific problems useState doesn't cover: holding a value that doesn't trigger a re-render when it changes, and reading a value without threading it through every intermediate component's props.

[def] useRef: a mutable box that survives re-renders without causing one

const inputRef = useRef(null) creates an object with a single .current property React never re-renders on when it changes — useful for holding a reference to an actual DOM node (<input ref={inputRef} />, then inputRef.current.focus()), or any value you need to track across renders that shouldn't itself drive the UI, like a previous value for comparison or a timer ID.

useContext: skipping prop-drilling through five intermediate componentsjsx
const AuthContext = createContext(null);

function App() {
  const [user, setUser] = useState(null);
  return (
    <AuthContext.Provider value={user}>
      <ConversationList />   {/* doesn't need user itself */}
    </AuthContext.Provider>
  );
}

function MessageComposer() {
  const user = useContext(AuthContext);   // reads it directly, however deep
  return <p>Posting as {user?.name}</p>;
}

[retail] Context solves prop-drilling; it is not a general state management replacement

Every component that consumes a context re-renders whenever that context's value changes, with no fine-grained control over which specific field changed. That's fine for something that changes rarely — the logged-in user, a theme — and a real problem for something that changes on every keystroke, like the message draft from 10.9. Frequently-changing, widely-shared state is exactly the case dedicated state-management libraries exist for, a boundary worth knowing rather than reaching for context everywhere by default.

11.13 Custom hooks and the rules of hooks

A custom hook is nothing more than a function whose name starts with use and that calls other hooks inside it. The payoff is real: shared stateful logic, extracted once, instead of copy-pasted across every component that needs it.

Extracting a pattern used in every streaming section from Part Fjsx
function useEventSource(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const source = new EventSource(url);
    source.onmessage = (e) => setData(JSON.parse(e.data));
    source.onerror = (e) => setError(e);
    return () => source.close();
  }, [url]);

  return { data, error };
}

// Any component can now do:
const { data, error } = useEventSource("/chat/completions");

[+] This is DRY applied to React specifically

Without useEventSource, every component that needs to consume a Server-Sent Events stream from chapter 9's API would duplicate the connect/parse/cleanup logic, and a bug fixed in one copy stays broken in the others — the identical argument chapter 9 made for shared FastAPI dependencies, applied here to shared frontend logic instead of shared backend logic.

[def] The two rules of hooks, and why they exist

Only call hooks at the top level
Never inside a loop, a condition, or a nested function. React tracks hooks by the order they're called in on each render, not by name — call one conditionally, and a render where the condition differs shifts every subsequent hook's position, silently attaching the wrong stored state to the wrong hook.
Only call hooks from React functions
A component, or another custom hook — never a plain utility function. This is what lets React's tracking-by-call-order actually work: only React itself knows to reset that order at the start of each render.

11.14 The virtual DOM and reconciliation

11.1 promised that describing what the UI should look like is enough — the framework figures out the actual DOM changes. This is the mechanism that keeps that promise for React specifically.

[def] The virtual DOM is a plain JavaScript description of the tree, not a copy of the real DOM

Every time a component re-renders, React calls its function again and gets back a lightweight object tree describing the desired output — not real DOM nodes, just plain JavaScript objects, which are far cheaper to create and compare than actual browser elements. Reconciliation is the process of comparing this new tree to the previous one and computing the minimal set of real DOM operations needed to make the browser match it.

[+] Why this is faster than naive re-rendering, in the cases it actually is

Directly touching the real DOM is comparatively expensive — it can trigger layout recalculation and repaint. Diffing two plain JavaScript object trees and then issuing one batched set of real DOM updates is usually cheaper than re-creating the entire DOM subtree from scratch on every state change, which is the naive alternative the virtual DOM avoids. It is not free, though — Svelte and Solid's fine-grained reactivity (11.2) skips this diffing step entirely by tracking dependencies directly, which is precisely why they can beat React on raw update speed in benchmarks.

[retail] Keys (11.7) are the hint that makes diffing correct on lists specifically

Reconciliation compares trees position by position by default. For a list, that means without a stable key React has no way to tell "item 3 moved to position 1" apart from "item 1 was replaced with new content" — both look identical as a plain positional diff. The key is what lets React's reconciler treat a reorder as a move instead of a series of destroys-and-recreates, which is the concrete mechanism behind 11.7's warning about index keys breaking on reorder.

11.15 Fiber and interruptible rendering

React's reconciler was rewritten in 2017 specifically to solve a problem the original version couldn't: a large re-render could block the browser's main thread long enough for the page to feel frozen, with no way to interrupt it partway through.

[hist] Fiber: reconciliation broken into interruptible units of work

Before Fiber, reconciling a large tree was one uninterruptible synchronous pass — once started, it ran to completion before the browser could do anything else, including responding to a keystroke. Fiber restructured reconciliation into small units of work React can pause, hand control back to the browser for something more urgent (like that keystroke), and resume — the foundation that later features like Suspense (11.17) and priority-based updates are built on.

[+] Why this matters practically, without needing the internals

You will rarely write code that interacts with Fiber directly. What it buys you as an application author is that React can now treat some updates as more urgent than others — typing into a text box stays responsive even while a large, unrelated part of the tree is mid-re-render — a distinction the pre-Fiber synchronous reconciler had no way to express at all.

11.16 Controlled vs. uncontrolled components

A form input can either have React own its value, or let the DOM own it and ask for the value only when needed. Both are legitimate; picking without knowing the trade-off is where the confusion comes from.

Uncontrolled: the DOM owns the value

function SearchBox() {
  const inputRef = useRef(null);
  const handleSubmit = () => {
    search(inputRef.current.value);   // read it only when needed
  };
  return <input ref={inputRef} />;
}

Controlled: React owns the value

function SearchBox() {
  const [query, setQuery] = useState("");
  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

[def] The trade-off, stated plainly

Controlled inputs give you the current value on every keystroke, for free — needed for live validation, character counts, or disabling a submit button based on content, all things a chat composer typically wants. That comes at the cost of a re-render on every keystroke. Uncontrolled inputs avoid that re-render entirely but only give you the value when you explicitly ask for it, which is enough for a simple form submitted as a whole and nothing more granular than that.

11.17 Error boundaries and Suspense

Two mechanisms for handling the two things that go wrong with a component that a normal function return value can't express: an exception thrown during render, and data that isn't ready yet.

[def] An error boundary catches a render-time crash before it takes down the whole app

Without one, an uncaught exception thrown while rendering any component unmounts the entire React tree, leaving a blank page. An error boundary is a component that catches errors thrown by its children during render and shows a fallback UI instead — scoped to just that part of the tree, so a broken message-rendering component doesn't take the whole chat application down with it, only its own section.

Suspense: declaring a loading state for data that isn't readyjsx
<Suspense fallback={<Spinner />}>
  <ConversationHistory conversationId={id} />
</Suspense>

[+] Suspense moves "what shows while this loads" out of every component and into the tree structure

Instead of every component that fetches data managing its own isLoading boolean and conditional rendering (the pattern from 11.7), a component wrapped in Suspense can simply "suspend" while its data isn't ready, and the nearest Suspense boundary shows the fallback until it resolves. This composes cleanly with error boundaries: a fetch that fails is an error-boundary concern, a fetch that's still pending is a Suspense concern, and a single component doesn't have to juggle both cases itself.

11.18 Code splitting and lazy loading

Every component in an application doesn't need to be in the JavaScript bundle the browser downloads before showing anything. Code splitting is how a large application avoids shipping code for screens the current user hasn't navigated to yet.

A settings panel loaded only when actually openedjsx
const SettingsPanel = lazy(() => import("./SettingsPanel"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      {showSettings && <SettingsPanel />}
    </Suspense>
  );
}

[def] lazy() and Suspense are designed to work together, not by coincidence

lazy() tells the bundler to split SettingsPanel's code into its own file, downloaded only when this line actually runs. Because that download is asynchronous, React needs a way to show something while it's in flight — which is exactly what Suspense from 11.17 already does for any not-yet-ready data, reused here for not-yet-downloaded code instead.

[retail] Route-level splitting is where this pays off with the least effort

Splitting at the level of entire pages or routes — the conversation view, the settings page, an admin panel most users never open — captures most of the benefit for the least restructuring, because a user only ever needs one route's code at a time. Splitting every individual small component is rarely worth the added complexity; 11.20 covers how to tell whether splitting is actually solving a real bundle-size problem or just adding network round-trips for no measured benefit.

11.19 Why components re-render, and how to stop the unnecessary ones

A component re-renders when its own state changes, when its parent re-renders, or when a context it reads changes. The second cause is the one that surprises people: a parent re-rendering re-renders every child by default, whether or not that child's actual props changed.

[!] "My component re-rendered" is not automatically a performance problem

A re-render that produces the same virtual DOM output as before is cheap — reconciliation (11.14) diffs it, finds no actual changes, and touches nothing in the real DOM. The expensive case is a component doing genuinely heavy work inside its render function on every one of those re-renders, not the re-render itself. Measure before optimising; 11.21 covers the tool for that.

React.memo: skip re-rendering a child if its props haven't changedjsx
const MessageBubble = React.memo(function MessageBubble({ text, timestamp }) {
  return <div className="bubble">{text}<span>{timestamp}</span></div>;
});

[def] React.memo compares props shallowly, which is exactly where it stops working

React.memo skips re-rendering MessageBubble if every prop is === to its previous value. That check breaks the moment a parent passes an inline object, array, or function as a prop — an inline arrow function passed as onClick creates a brand new function on every parent render, so the shallow comparison always sees a "changed" prop and re-renders anyway. This is exactly why useCallback from 11.11 exists: to hand a memoized child a stable function reference instead of a fresh one every time.

11.20 Bundle size and the cost of JavaScript

Every kilobyte of JavaScript shipped to the browser has to be downloaded, parsed, and executed before the page is interactive — a cost that compounds badly on a slow connection or an underpowered device, unlike most backend performance costs, which are invisible to the end user.

[def] Tree shaking and code splitting attack the bundle from two different angles

Tree shaking removes code that is imported but never actually used, at build time — importing one function from a large utility library should not ship the whole library if the bundler can prove the rest is dead code. Code splitting from 11.18 instead accepts that all the code is genuinely used somewhere, and defers downloading the parts not needed for the current screen. Both reduce what a user has to download before your app is usable; they solve different halves of the problem.

[!] A dependency's advertised size and its actual cost in your bundle can differ wildly

A library documented as "twelve kilobytes" is often that size minified and compressed in isolation, not accounting for whether tree shaking can actually eliminate the part of it your code does not use, which depends on how that library itself is packaged. Two libraries offering the same feature can add wildly different amounts to a real bundle for reasons invisible from their README, which is why measuring the actual built bundle, not trusting a library's marketing page, is the only reliable check.

11.21 Virtualization and profiling in production

Two more tools worth knowing before calling a React application "optimised": rendering only the visible portion of a very long list, and actually measuring where render time goes instead of guessing.

[def] Virtualization: only the rows on screen exist in the DOM at all

A conversation history with a very large number of messages does not need a DOM node for every one of them — only the handful currently visible in the viewport. A virtualization library renders just those, recycling the same small set of DOM nodes as the user scrolls and swapping their content, rather than the browser having to lay out and paint every message that exists whether or not it is on screen.

[retail] This is chapter 4's pagination problem, one layer up the stack

Chapter 4 would not load a billion vectors into memory to answer one query; chapter 9's section 9.7 would not return every row of a growing table in one response. Virtualization is the same instinct applied to the DOM: do not materialise more than what is actually being looked at right now, whether that materialising means a database result set, an HTTP response body, or real DOM nodes.

[+] The React DevTools Profiler answers "which component, how long" directly

Rather than guessing which component is slow from reading code, the Profiler records an actual render pass and shows exactly which components rendered, how long each one took, and why each one rendered — props changed, state changed, or a parent re-rendered. This is the tool that turns 11.19's "measure before optimising" from advice into something you can actually do in under a minute, on the real application, with real data.

11.22 Consuming a streaming endpoint in React

Chapter 9 built the server side of an SSE endpoint. This section builds the client side that consumes it, using the custom hook pattern from 10.13.

A hook that streams tokens from chapter 9's /chat/completions endpointjsx
function useChatCompletion() {
  const [tokens, setTokens] = useState([]);
  const [status, setStatus] = useState("idle");

  const send = useCallback(async (messages) => {
    setTokens([]);
    setStatus("streaming");
    const response = await fetch("/chat/completions", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages }),
    });
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      for (const line of decoder.decode(value).split("\n")) {
        if (!line.startsWith("data: ")) continue;
        const event = JSON.parse(line.slice(6));
        setTokens(prev => [...prev, event.token]);
      }
    }
    setStatus("done");
  }, []);

  return { tokens, status, send };
}

[def] Why fetch with a readable stream, not the browser's EventSource

9.18 noted that the native EventSource API cannot set request headers, which rules it out the moment an Authorization header is required — the normal case for an authenticated chat endpoint from 9.21. Reading response.body as a stream directly, with fetch, gives up automatic reconnection but allows a full set of request headers, which is the trade almost every authenticated SSE client in React actually makes.

[!] A chunk from the stream reader is not guaranteed to be one complete event

TCP and HTTP have no obligation to deliver one SSE event per reader.read() call — a single chunk can contain a partial event, several complete events, or an event split across two chunks. A production implementation buffers incomplete lines across calls to reader.read() rather than assuming, as the simplified example above does, that each chunk splits cleanly on newlines.

11.23 Chat state: history, loading, retry, and cancellation

A chat UI's state is more than the current stream. It has to track a growing message history, distinguish several loading states from each other, and support both retrying a failed generation and cancelling one that's still in progress.

[def] Three states a chat message can be in, and why collapsing them into one boolean fails

"Loading" is not one state: a message can be pending (sent, waiting for the first token), streaming (tokens arriving), or errored (the model_unavailable or other error code from chapter 9's 9.6 and 9.9 came back instead). A single isLoading boolean cannot distinguish "still waiting" from "failed," which is exactly the ambiguity 9.18 solved server-side with an explicit done event — the client-side state needs the same precision, not a single collapsed boolean.

Cancellation via AbortController, checked on the server via 9.19's is_disconnectedjsx
function useChatCompletion() {
  const controllerRef = useRef(null);

  const send = useCallback(async (messages) => {
    controllerRef.current = new AbortController();
    await fetch("/chat/completions", {
      method: "POST",
      signal: controllerRef.current.signal,
      /* ...as before */
    });
  }, []);

  const cancel = useCallback(() => {
    controllerRef.current?.abort();   // closes the connection; the server sees the disconnect
  }, []);

  return { send, cancel };
}

[retail] Cancelling in the browser and 9.19's server-side check are two halves of one feature

Calling abort() closes the underlying connection from the client. On its own that is only half the win: chapter 9's 9.19 covered detecting a disconnected client with FastAPI's request.is_disconnected() and stopping token generation server-side, so an abandoned request stops consuming GPU capacity, not just stops updating a UI nobody is looking at. Building the cancel button without the server-side check gives users a working stop button and the backend a generation that quietly runs to completion anyway.

11.24 Rendering streamed markdown safely

An LLM's response usually arrives as markdown, and it usually needs to render as formatted HTML, and it is arriving one token at a time. All three of those facts together create a problem plain string concatenation does not solve safely.

[!] Never render model output with dangerouslySetInnerHTML directly

The name is not exaggerating: injecting raw HTML into the DOM from a string is exactly how a cross-site scripting vulnerability gets introduced, and an LLM's output is untrusted input by the same logic chapter 9's 9.5 applied to any client-submitted data — it came from a model that can be prompted, directly or indirectly, into producing content you did not intend to render as-is. A markdown renderer that parses to a safe element tree, rather than raw HTML injection, is the correct default.

[def] Parsing incomplete markdown mid-stream is a real, separate problem from parsing it once it's done

A markdown parser expects complete input — a code fence that has not been closed yet, or a bold marker with no closing pair, can render incorrectly or flicker if re-parsed from scratch on every token. The common approach is re-parsing the accumulated text on every token arrival (cheap enough for a single chat message) while tolerating that formatting may visually settle a moment after the tokens matching it have arrived, rather than trying to parse a markdown stream incrementally, which most libraries are not built to do at all.

[+] This closes the loop the whole chapter has been building toward

Chapter 9 designed an API that could stream tokens safely and efficiently over HTTP. This chapter built the component model, the hooks, and the rendering discipline to consume that stream, manage its state honestly, and display it without introducing a security hole in the process. A production AI chat interface is exactly this stack, assembled with the specific care each layer needed.

11.25 Key takeaways

The twelve things worth remembering

  1. Every frontend framework solves the same problem: keeping the DOM truthful as state changes. React, Vue, Svelte, Angular, and Solid disagree about the mechanism, not the goal.
  2. React's technical edge is not the whole story. Svelte compiles away its runtime, Vue has a gentler learning curve, and Solid matches React's ergonomics with better raw performance — React's default status rests more on ecosystem size, hiring pool, and its meta-framework layer than on any single technical advantage.
  3. "What does my team already know" is a legitimate deciding factor. Choosing an unfamiliar framework for marginal benchmark gains routinely costs more in ramp-up time than it saves.
  4. A component is a pure function from props and state to markup. Props flow down from a parent and cannot be changed by the component that receives them; state is owned locally and changed only through its own setter.
  5. A missing or unstable list key is not cosmetic. It is what tells React's reconciler whether a list item moved, and getting it wrong can attach the wrong component's internal state to the wrong row after a reorder.
  6. The dependency array is the single most consequential detail in the hooks API. An omitted dependency captures a stale value in a closure; the fix is adding it honestly, not silencing the linter that caught it.
  7. useMemo and useCallback are memoization, not a performance ritual. They cost their own overhead and are worth it only for genuinely expensive work or to preserve a stable reference something else depends on.
  8. Context solves prop-drilling; it is not a state management system. Every consumer re-renders on any change to the context value, which makes it a poor fit for anything that changes on every keystroke.
  9. React.memo's shallow comparison breaks on inline objects and functions. A memoized child re-renders anyway if its parent passes a new function reference every render, which is exactly what useCallback exists to prevent.
  10. Measure before optimising, using the actual Profiler. A re-render that produces identical output is cheap; the cost worth chasing is expensive work happening inside a render function, not the render itself.
  11. Never render LLM output as raw HTML. Model output is untrusted input by the same logic as any other client-facing data; a markdown renderer that produces a safe element tree is the correct default, not dangerouslySetInnerHTML.
  12. A streaming chat UI is chapter 9's SSE endpoint plus honest client state. Distinct pending, streaming, and errored states, a cancel button wired to an AbortController that the server actually checks for, and a markdown renderer that re-parses safely on each token are what turn a working stream into a production interface.

[def] The one-sentence version

Pick a framework based on what your team already knows and what your product actually needs, not a benchmark; if that's React, learn props and state before hooks, hooks before Fiber internals, and treat performance work as something you measure into, never guess into; and remember that a streaming AI interface is a normal component tree with unusually careful state, cancellation, and rendering discipline layered on.

11.26 Interview drills

React interview questions split cleanly into two kinds: "explain the mechanism" and "diagnose the bug." Both are covered below, because interviewers rarely stop at the first once they sense you actually understand the second.

1. Why did React become the dominant frontend framework when Vue and Svelte are arguably better on specific technical metrics?

Because the deciding factors in practice are rarely the ones a benchmark measures. React had a multi-year head start, which compounded into the largest ecosystem of components, patterns, and hiring pool of any of them; Next.js built server-side rendering and routing on top of React specifically, pulling in an entire category of production applications years before Vue or Svelte had equally mature equivalents.

I would not claim React is technically superior — Svelte's compiled output is genuinely smaller, and Vue's learning curve is genuinely gentler. What React offers today is ecosystem gravity, which is a real and defensible reason to choose it, just not a claim about the underlying rendering algorithm being the best one available.

2. What actually happens when you call a useState setter, and why does reading the state variable on the next line still show the old value?

Calling the setter does not mutate the variable in place. It schedules a re-render in which the component function will be called again, and on that next call the state variable will hold the new value. Nothing changes synchronously at the point the setter is called; the update is applied on the next render pass.

That is exactly why reading the variable immediately after calling its setter still shows the previous value — the current execution of the function is still using the closure over the old state. If code needs to act on the new value immediately, it should use the value being passed to the setter directly, or move that logic into an effect that depends on the state.

3. A useEffect reads a piece of state but the linter's exhaustive-deps rule wasn't satisfied, so a teammate suppressed the warning. What's likely to go wrong?

The effect's callback is a closure captured at the time the effect last ran. If the state it reads is left out of the dependency array, the effect keeps using whatever value that state had when the effect was created, not its current value on subsequent renders — a stale closure bug. Suppressing the linter warning silences the symptom without fixing the actual cause.

The correct fix is almost always to add the dependency honestly, which makes the effect re-run whenever that value changes. If that causes the effect to re-run more often than intended, that's usually a signal the effect is structured wrong — for instance, deriving a value that should just be computed directly during render instead of inside an effect at all.

4. A list of items breaks in a specific way after reordering: the wrong item's text input keeps its previously typed value. What's the root cause?

The list is almost certainly using the array index as the key instead of a stable ID from the actual data. Reconciliation matches old and new elements by key across renders; with an index key, an item that moves from position 3 to position 1 gets matched against whatever was previously at position 1, not against its own previous instance, because the key values did not move with it.

Since a text input's typed value lives in that DOM node's own internal state, not in React's virtual representation, the reconciler ends up leaving that DOM node's internal state attached to the wrong logical item after the reorder. The fix is a key derived from the item's own stable identity, not its position in the array.

5. When would you actually reach for useMemo, and when is it a wasted instinct?

When the computation being memoized is genuinely expensive, or when the stability of the returned reference matters to something else — a dependency array elsewhere, or a prop passed into a React.memo-wrapped child that would otherwise re-render on every parent render because it sees a new object reference each time.

It's wasted the moment the computation itself is cheap: useMemo has its own overhead from storing the previous inputs and comparing them on every render, and for something like formatting a date or summing a short array, that bookkeeping costs more than just redoing the work. I would default to plain computation and add memoization only once I've actually measured a cost worth avoiding, not reflexively on every derived value.

6. What is the difference between the virtual DOM and Fiber, and why does an interviewer sometimes ask both as if they were the same thing?

The virtual DOM is the data structure: a plain JavaScript object tree describing what the UI should look like, which React diffs against the previous tree to compute the minimal real DOM changes needed. Fiber is the reconciler implementation that performs that work — specifically, the 2017 rewrite that broke reconciliation into small, interruptible units instead of one uninterruptible synchronous pass.

They get conflated because Fiber operates on the virtual DOM representation, so in casual conversation "the virtual DOM" sometimes stands in for the whole reconciliation process. I'd draw the distinction explicitly: virtual DOM is what's being compared, Fiber is how the comparison and resulting update work is scheduled and can be paused for something more urgent, like a keystroke.

7. What's the actual difference between a controlled and an uncontrolled input, and when would you choose the uncontrolled one deliberately?

A controlled input's value is held in React state and passed back in via the value prop, with every keystroke going through an onChange handler that updates that state — meaning React re-renders on every keystroke, but the current value is always available for validation, character counts, or conditional UI. An uncontrolled input lets the DOM manage its own value, read only when needed via a ref.

I'd choose uncontrolled for a simple form submitted as a whole, where nothing needs to react to individual keystrokes and avoiding the re-render is a genuine, if usually small, win. Anything needing live feedback as the user types — a chat composer disabling send on empty input, a live search box — needs the value on every keystroke, which means controlled is the only option that actually works.

8. Your team wants to render an LLM's markdown response in a React chat UI. What's the wrong way to do it, and why is it a security issue, not just a style preference?

The wrong way is converting the markdown to an HTML string and rendering it with dangerouslySetInnerHTML. The model's output is untrusted input in exactly the same sense as any other client-facing or externally sourced data: a model can be prompted, directly or through injected content it was asked to summarise, into producing script tags or event handler attributes that execute in the user's browser session — a straightforward cross-site scripting vector.

The correct approach is a markdown renderer that parses to a safe element tree rather than raw HTML, so there's no string of HTML ever being injected directly. I'd also flag that this needs to work correctly on incomplete markdown mid-stream, since tokens arrive incrementally, not treat it as a problem that only exists once the full response has arrived.

9. A user clicks "stop" on a streaming chat response. What actually needs to happen for this to be a real fix rather than a cosmetic one?

Two things, not one. On the client, an AbortController tied to the fetch call needs to actually abort the request, which closes the underlying connection. That alone only stops the UI from updating — if the backend doesn't notice the client disconnected, it keeps generating tokens into a socket nobody is reading.

The other half has to live on the server: detecting the disconnected client — FastAPI's request.is_disconnected() is the concrete mechanism — and actually stopping generation at that point, freeing the GPU capacity slot it was holding against a concurrency ceiling. Building only the client-side stop button gives users a working-looking button that doesn't actually save any inference cost.

10. When does it actually make sense to reach for a framework other than React on a new project?

Three cases come up genuinely often. A team with no prior frontend experience often ramps up faster on Vue's gentler mental model and single-file components than on React's hooks rules and JSX together. A product where bundle size and load time on slow connections is the dominant constraint benefits measurably from Svelte or Solid's smaller runtime footprint. A large TypeScript-first enterprise organisation converging many independent teams often benefits from Angular's prescribed structure, even at the cost of more boilerplate per feature.

Outside those specific situations, I'd weight existing team expertise heavily — a team of React developers rebuilding fluency in a new framework for a marginal technical gain usually costs more in the first quarter than it saves over the project's lifetime, and there's nothing unsophisticated about factoring that in as a legitimate part of the decision.

Where this leaves you

You can now make an informed case for React specifically — not by default, but against a real field of alternatives — and build with it correctly: components and the data flow between them, the hooks that manage state and side effects without the stale-closure and dependency-array traps that catch most newcomers, the rendering internals that explain why performance work needs measurement instead of guessing, and the specific discipline a streaming AI chat interface needs on top of all of it.

The thread running through this chapter mirrored chapter 9's: understand the mechanism before reaching for the API that wraps it, know what the alternatives actually do differently before defaulting to the popular choice, and treat an LLM response as a fundamentally different kind of data — arriving over time, untrusted in content — rather than a slower version of an ordinary API response.

Chapters 1 through 8 built the AI stack: what a model is, how it's served, how it's orchestrated, and how it acts. Chapter 9 built the API that fronts it. This chapter built the interface a real person actually uses. The next chapter goes underneath all of them, to the data layer everything here ultimately reads from and writes to — and the question of which database to use for which job, which has a better answer than "whichever one the team already runs."