TypeScript: Frontend with React
react, components, hooks, state management, reconciliation, virtual dom, typescript props, data fetching, unidirectional data flow, rendering
Introduction
The bug report said the dashboard “sometimes shows the old number.” It was a counter in the corner of a trading screen, and once or twice a day it lagged a tick behind the data feeding it — never reproducibly. The engineer who inherited it found the number was updated in four different places. A WebSocket handler wrote it straight into the DOM with element.textContent. A polling timer overwrote that a second later. A click handler on a neighboring filter recomputed it from a cached array. A “refresh” button set a variable that a render function read on the next frame, whenever that was. Four writers, no owner. The counter wasn’t wrong because any one write was wrong; it was wrong because nobody could say, for a given moment, which write had won.
This is what UI code becomes when state lives in ten places and mutations fire from everywhere. The DOM is a giant mutable object, and the most natural thing in the world is to reach in and change it — set this text, toggle that class, append this row. Each change is trivial alone. The trouble is that the screen is the sum of every change ever made to it, in whatever order they fired, and that order is a function of clicks, timers, and network latency nobody controls. There is no single description of “what the UI should look like right now,” only the accumulated residue of imperative pokes. Ask why a component shows what it shows and the honest answer is a debugging session, not a sentence.
React’s whole proposition is to make that question answerable. Instead of mutating the DOM by hand from a dozen call sites, you write one function that says what the UI should look like for a given state, and let React make the screen match. The counter has exactly one owner — the state — and the display is just a view of it; change the state and the view follows, and that is the only way it ever changes. The trading screen’s unpredictability wasn’t a React-shaped problem waiting for a React-shaped fix. It was the absence of the idea this chapter is about: the UI as a predictable function of state.
The Core Insight
The core idea fits in four characters: UI = f(state). The user interface is the output of a pure function whose input is your application’s state. You don’t issue a sequence of DOM commands; you write a function that, given the current state, returns a description of the UI that state should produce. React calls that function, takes the description, and makes the real DOM match it. When the state changes, React calls the function again and makes the DOM match the new description. You never touch the DOM yourself.
This inverts the model the trading screen was built on, and it dissolves three problems that imperative UI code can’t escape:
- No single source of truth. When the screen is the residue of scattered mutations, no one place holds “the current value.” React forces state to be explicit and named and makes the display a derived view of it — so the value has exactly one home.
- No way to reason about a render. Imperatively, the current pixels depend on history — the order writes fired. Declaratively, they depend only on the current state, because the render function is pure: the same state always produces the same UI, which is the property that makes a UI testable.
- Manual, error-prone DOM surgery. Hand-written DOM updates are where the bugs live — the stale text, the class you forgot to remove, the row you appended twice. React removes that surgery: you describe the destination, it computes and applies the diff.
The mechanism that makes “call the function again on every change” affordable is reconciliation (the focus of a later section). Re-rendering doesn’t rebuild the page: React keeps a lightweight in-memory virtual tree of what the DOM should be, diffs the new description against the old, and touches only the real nodes that changed. Describing the whole UI from scratch is cheap because the description is plain objects, not DOM, and the diff keeps the expensive part — mutating the browser — minimal.
The other half of the model is direction. State flows down: a parent owns state and passes it to children as props, read-only inputs the child renders from. Events flow up: a child can’t reach into its parent’s state, so it calls a function the parent handed it, and the parent updates its state, which flows back down. This is unidirectional data flow, and it’s why you can answer “why does this component show what it shows” — you trace one direction, from the state that owns the value down through props. TypeScript makes those contracts checkable: props are a typed interface, so a wrong or missing prop is a compile error, and the discriminated unions that model “loading vs. error vs. data” force you to handle every case before reading the data. The whole UI = f(state) equation becomes a typed function the checker holds you to.
A mental model
The cleanest way to hold this is to picture a spreadsheet. A cell with =A1+B1 doesn’t do anything when you change A1 — it doesn’t run an update routine or poke at its own contents. It simply is the sum, and the sheet recomputes it whenever an input changes. You never write “when A1 changes, update C1”; you declare what C1 is in terms of its inputs, once, and the recomputation is the sheet’s job. A React component is a cell: it declares what it is in terms of props and state, and React handles the recomputation when an input changes. Your job is the formula, not the update.
A companion image sharpens it: a component is a pure function from props and state to UI — same inputs, same output, no side effects in the body — which is exactly why React can call it as often as it likes without consequence. And the data flows one direction, from the state at the top through props to the leaves; events are how a leaf sends a message back upstream. When a UI feels unpredictable, it’s almost always because someone built a tributary flowing the wrong way — a child mutating a parent’s data, or state duplicated in two cells that drift apart.
When React fits (and when it’s overkill)
React is a default for one specific shape of problem: a rich, interactive UI with a lot of state. An application where the screen changes constantly in response to user actions and live data — a dashboard, an editor, a multi-step form, a collaborative tool — is exactly what UI = f(state) was built for. The more state and interactivity you have, the more the imperative approach collapses under its own bookkeeping, and the more React’s “describe the destination” model earns its keep. Figure 15.1 shows the cycle that makes this work.
React is overkill for a page that is mostly content and barely interactive — a marketing page, a blog post, a docs site. Wrapping those in React ships a runtime, a build pipeline, and a hydration step to animate a page that didn’t need animating; a static-site generator or plain HTML renders faster and breaks less. The honest cost of React is the ecosystem — a bundler, a state library or two, a data-fetching library, a test setup, and the churn of keeping them current — trivially worth it for an application and pure overhead for a brochure. The deciding question is not “is this a website” but “how much does this UI’s appearance depend on changing state.” A lot: React. Almost none: skip it.
What you’ll learn
- How to model UI as a typed function of state, so the screen is a predictable view of data rather than the residue of scattered mutations
- How to write function components with typed props and children, and let TypeScript enforce the props contract at the call site
- How React’s render → reconcile → commit cycle works, why and when a component re-renders, and why
keyis load-bearing in lists - How to use
useStateanduseEffectcorrectly — effects as synchronization, not lifecycle hooks — and how dependency arrays cause infinite loops and stale closures - How to decide where state belongs: local, lifted, or global, and why server state is a different animal from client state
- How to fetch data with explicit loading and error states, and when to reach for a data library instead of hand-rolled effects
Prerequisites
- TypeScript: Fundamentals — interfaces, generics, and union types, which is the language props and state contracts are written in
- Type Systems and Generics — discriminated unions and
infer-style derivation (the TypeScript material in that chapter), used to model component state and derive types from schemas - Comfort with the DOM and HTML, and with
async/awaitfor the data-fetching section
Components and typed props
A component is a function: it takes one argument — an object of props — and returns a description of UI written in JSX, the HTML-like syntax that compiles to plain function calls. Props in, UI out. Typing it is just typing the parameter: declare an interface, annotate the parameter, and TypeScript checks every call site, so a number where the interface says string, or a missing required prop, fails to compile — the props contract is enforced exactly where UI bugs otherwise slip in. Write components as plain functions rather than the older React.FC type (more honest about inputs, composes better with generics, doesn’t fold children in implicitly); when a component accepts children, type them explicitly as React.ReactNode, the type of anything renderable.
// A component is a typed function: props in, JSX out.
interface UserCardProps {
name: string;
email: string;
onEdit?: (name: string) => void; // optional event-up callback
children?: React.ReactNode; // arbitrary nested UI
}
export function UserCard({ name, email, onEdit, children }: UserCardProps) {
return (
<article className="user-card">
<h3>{name}</h3>
<p>{email}</p>
{onEdit && <button onClick={() => onEdit(name)}>Edit</button>}
{children}
</article>
);
}
Two details there are the whole pattern in miniature. The onEdit prop is a function passed down so the child can send an event back up — the child never edits anything itself; it calls the callback and lets the owner decide. And {onEdit && ...} is just JavaScript: JSX has no template language for conditionals or loops, because the UI description is built with ordinary expressions — a list is items.map(...), a conditional is && or a ternary, and the type checker follows along the whole way.
The rendering and reconciliation model
This is the center of the chapter, where intuition either clicks or quietly stays broken. When state changes, React re-renders — it calls your component function again. Calling it does not touch the DOM; it produces a fresh description, a tree of plain objects saying what the DOM should be. React then reconciles that against the previous render’s description, computes the difference, and commits only that difference to the real DOM. Three phases: render (pure, produces a description), reconcile (diff against the last description), commit (apply the minimal DOM change, then the browser paints). Figure 15.1 is this loop drawn out.
The render phase being pure is the rule that prevents the most expensive class of bugs. Because React may call your function many times for one visible update — and in development deliberately calls it twice to surface impurities — the body must be a calculation and nothing else: no fetching, no subscribing, no writing outside the function, no setState. Side effects belong in effects (next section); the body only computes. Treat a component’s body as if it might run at any moment, any number of times.
A component re-renders for exactly three reasons, and knowing them ends most “why did this render again” confusion: its own state changed, a context it consumes changed, or its parent re-rendered. That last one catches people — a parent render re-renders its children by default, even with identical props. This is usually fine, because rendering is just calling functions and diffing objects, and reaching for memoization to prevent it should be a measured decision, not a reflex. Most apps never need it.
The one place reconciliation needs your help is lists. When the array you render changes — a row inserted, removed, reordered — React has to match each new description to an element from the previous render to know what to keep, move, or destroy. By default it matches by position, which is wrong the moment the list reorders: insert a row at the top and React thinks every row’s content changed, when really one row was added. The fix is a stable key on each item, a string that identifies which item this is across renders, independent of position.
// `key` lets reconciliation track items across reorders. Use a stable id —
// never the array index, which changes when the list reorders.
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
The temptation is the array index, because it’s always there. Resist it: the index is the position, so an index key tells React the very thing that breaks — that item three is whatever sits at slot three now. Reorder and React reuses the wrong DOM nodes, which shows up as inputs keeping the wrong values and animations on the wrong rows. Use a stable identifier from the data that names the thing, not its spot in line.
Hooks: state and synchronization
Functions don’t remember anything between calls — so where does state live across renders? In hooks. A hook is a use* function that attaches persistent machinery to the component instance: a state value, an effect, a memoized result. React tracks them positionally, in call order, which is the reason for the one inviolable rule — call them unconditionally, at the top level, in the same order every render. No hooks inside if or loops; change the call order and React’s positional bookkeeping silently maps your state to the wrong slot.
The foundational hook is useState. It returns a pair — the current value and a setter — and calling the setter is how you change state, the only way to trigger a re-render. You never reassign the value directly; you call the setter, React schedules a re-render, and the next render sees the new value. The value is typed, so the setter is too, and the discriminated unions that model real UI states (idle, loading, error, success) are checked end to end.
// State is the single source of truth; the UI is derived from it.
// We never read or write the DOM — we change state and describe the result.
function Counter() {
const [count, setCount] = React.useState(0); // typed as number
return (
<button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>
);
}
That setter takes a function — (c) => c + 1 rather than count + 1 — for a reason worth pausing on: each render captures its own count, so computing from the current value rather than the captured one is what keeps rapid updates correct. It’s the same closure behavior that, mishandled, produces the stale-value bugs we’ll hit in a moment.
The second core hook, useEffect, is the one everybody misunderstands, because it’s usually taught as a lifecycle hook — “run on mount, clean up on unmount.” That framing is the source of most effect bugs. The honest model is different: an effect synchronizes your component with an external system. It’s how you reach outside the pure UI = f(state) world — to the network, a subscription, a browser API, a non-React widget — and keep that outside thing in sync with your state. You don’t ask “when does this run”; you ask “what external thing should match this state, and what does it depend on.” The answer to the second question is the dependency array.
That array is where the hazards live. React re-runs the effect whenever any value in it changes; list the wrong values and you get the two classic failures. Omit a dependency the effect uses, and it closes over a stale value — captured on the render it was created and never updated, so it acts on yesterday’s state. Put an unstable value in the array — an object or function recreated every render — and the array looks “different” every time, so the effect re-runs every render; if it also sets state, you’ve built an infinite loop. The linter (eslint-plugin-react-hooks) catches the first; the second is on you to recognize. Every effect also needs a cleanup — return a function that tears down what the effect set up (unsubscribe, abort the fetch, clear the timer); React runs it before re-running the effect and on unmount, which prevents leaks and state-on-unmounted warnings.
A team shipped a dashboard widget that fetched in a useEffect. To pass the fetch’s options they built an options object in the component body — { userId, sort } — and listed it in the dependency array to refetch when the options changed. It refetched, all right: on every render, forever. The object was built fresh each render, so it was a new reference every time, so the array always looked different, so the effect always re-ran — and because the effect set state, every run triggered the next render, which built a new object, which re-ran the effect. The tab pegged a CPU core and the API saw a request storm from a single page. The fix was two lines: drop the object and depend on the primitives it was built from ([userId, sort]), constructing the object inside the effect. The lesson is that dependency arrays compare by reference, so an object or function in the array must be stable across renders — or the effect never settles.
The mirror-image mistake is just as common: cargo-culting useMemo and useCallback onto everything “for performance.” Both are targeted tools for keeping a value’s identity stable — useMemo caches a computed value, useCallback a function — and they earn their place only when something downstream depends on that identity (a dependency array, a memoized child) or the computation is genuinely expensive. Sprinkled everywhere by default they add allocation and dependency-array maintenance to buy nothing while making the code harder to read. Reach for them when you’ve measured a real re-render problem, not before.
State management: where state belongs
The hardest design question in a React app is not how to hold state but where. The default is the most local place that works: state used by one component lives in that component, in useState. The moment two siblings need the same value, you lift it up — move it to their nearest common parent, which owns it and passes it down to both as props, with callbacks for either child to request a change. Lifting is the workhorse; most “shared state” is just state living one level higher than you first reached for.
Prop-drilling — threading a value down through layers that don’t use it just to reach one that does — is the smell that you’ve outgrown lifting for a particular piece of state. The fix is context, React’s mechanism for making a value available to a subtree without passing it through every layer. Context is the right tool for genuinely global, slowly-changing values — the current user, the theme, the locale — and the wrong tool for state that changes often, since every consumer re-renders when the context value changes. The decision tree is short: local first, lift when shared, context for global-and-stable, and a dedicated store (Zustand, Redux, Jotai) only for cross-cutting client state complex enough to want middleware or devtools — which is rarer than the ecosystem’s enthusiasm suggests.
But the most important distinction here isn’t local-vs-global. It’s server state vs. client state, and conflating them is the single most common architecture mistake in React apps. Client state is data your UI owns and invents — a toggle, a form’s input, which tab is selected — and is trivially the source of truth. Server state is data that lives on a server and is merely cached in your UI (a list of users, a document, search results); it’s a copy of a truth that lives elsewhere and can go stale, so it needs caching, deduplication, refetching, and reconciliation with the server after a write. Cram server state into useState plus a manual useEffect fetch and you will, inevitably, reimplement all of that — badly. The next section is where that distinction pays off.
Data fetching: effects vs. a data library
You can fetch data with useEffect and useState, and for one simple, self-contained request it’s reasonable to. The shape is always the same and the discipline is non-negotiable: model the request as a state machine with explicit loading, error, and success branches, and render each. The version that bites people forgets there are three states — it reads data while it’s still undefined and renders a crash, or ignores failures and shows a blank screen when the network hiccups. A discriminated union makes the three states impossible to skip, because the type checker won’t let you read data until you’ve narrowed to the success case.
// Three explicit states; TypeScript won't let you read `data` until you've
// narrowed to "success". The cleanup aborts a fetch that outlives the component.
type State<T> =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: T };
function useUser(id: string): State<User> {
const [state, setState] = React.useState<State<User>>({ status: "loading" });
React.useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data: User) => setState({ status: "success", data }))
.catch((e: Error) => {
if (e.name !== "AbortError") setState({ status: "error", message: e.message });
});
return () => controller.abort(); // cleanup: cancel if id changes or unmount
}, [id]); // re-fetch only when the id actually changes
return state;
}
That hook is correct, and it’s also the ceiling of what hand-rolled fetching should aspire to — at which point you’ve reinvented the easy parts of a data library and none of the hard ones. The instant several components read the same data, or you want the list to refetch when the tab regains focus, or you want a write to optimistically update the cache and reconcile with the server, the manual approach turns into a pile of subtle cache-coherence code. This is the server-state problem from the last section, solved well by a server-state library — TanStack Query (React Query) is the common choice. You hand it a key and a fetcher; it gives you caching, request deduplication, background refetch, stale-while-revalidate, and the same explicit loading/error/data status — typed — for free.
// The same three states, but caching, dedup, and refetch are handled for you.
function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: async (): Promise<User[]> => {
const res = await fetch("/api/users");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
// returns { data, isPending, isError } — narrow on these, exactly as above
}
The rule of thumb: if the data lives on a server, reach for a server-state library rather than useEffect, and keep your own state for what the UI genuinely owns. The async machinery underneath — promises, await, cancellation, error propagation — is the subject of the Concurrency and Parallelism Models chapter; effects and data fetching are that material applied inside the render cycle.
Production frontend concerns
A shipped frontend is more than components. It’s built by a bundler — Vite is the current default — which compiles TypeScript and JSX, tree-shakes dead code, and emits the optimized JavaScript the browser runs. The lesson in its output is that bundle size is a feature: every kilobyte is parse-and-execute time before your app is interactive, and React’s dependencies add up fast. The primary lever is code-splitting — rather than one monolithic bundle, split it so the browser downloads a route’s or feature’s code only when it’s needed; React’s lazy plus Suspense makes a component load on demand, keeping the initial download to what the first screen needs.
The other production-critical concern is the type-safe boundary to the backend. The data crossing that wire is the seam where types most easily lie: the backend claims it returns a User, your frontend believes it, and a schema change ships a runtime crash the compiler never saw, because fetch returns any-shaped JSON you asserted into a type. The robust pattern is to validate at the boundary — parse the response against a schema (a tool like Zod) and derive the TypeScript type from it, so the runtime check and the static type can’t drift apart. When frontend and backend share types, the boundary is type-safe end to end. That shared contract is owned on the server side — the TypeScript: The Node Ecosystem chapter covers the backend half. This chapter assumes the wire exists and focuses on consuming it safely from the browser.
Build it → A full React + TypeScript frontend in production shape: Project 05: SaaS Web Platform wires components, typed props, server-state data fetching, and the type-safe API boundary into a complete application. For a real-time, state-heavy UI under pressure, Project 16: CRDT Collaboration drives a collaborative-editor TypeScript client where unidirectional data flow and careful state ownership are what keep a live, multi-user UI predictable.
Practical exercise
Difficulty: Level I · Level II · Level III
- Level I — Derive the UI from state. Build a small typed component — a counter, a toggle, or a filterable list — with
useStateand an event handler. The constraint: never read or write the DOM directly (nodocument.querySelector, notextContent). The displayed value must be derived from state in the render, and the event handler must do nothing but call the setter. Then write one sentence explaining how this differs from the imperative “find the element and update it” approach you’d reach for without React. - Level II — Fetch with three honest states. Add data fetching to your component from a real endpoint, modeling the request as a discriminated union with explicit
loading,error, andsuccessbranches, and rendering each. Put the fetch in auseEffectwith a correct dependency array and a cleanup that aborts the request. Then write a short paragraph walking through the render cycle: what triggers the effect, what happens to the component on eachsetState, and why the dependency array contains exactly the values it does. Deliberately break it — omit a dependency, or add an object literal to the array — and describe the symptom (stale data or infinite loop) and why it happens. - Level III — Design state ownership for a feature. Take a non-trivial feature — a multi-pane dashboard, an editor with a sidebar, a checkout flow — and write a short design note classifying every piece of its state: what’s local to one component, what should be lifted to a shared parent, what is server state (cached from an API, needing refetch and invalidation) versus client state (owned by the UI). Justify where, if anywhere, a global store is warranted, and argue the case against using one where lifting or context would do. The deliverable is the reasoning, not code: a reviewer should be able to read it and agree that each piece of state lives in the right place.
Summary
React’s proposition is a single equation, UI = f(state): you describe what the UI should look like for a given state, and React makes the real DOM match — diffing each new description against the last (reconciliation) and committing only the minimal change. You stop mutating the DOM from scattered call sites and make state the single source of truth, with state flowing down through props and events flowing up through callbacks — the unidirectional cycle that turns “why does this show what it shows” into a one-direction trace. Hooks hold the state and synchronize with the outside world: useState for values, useEffect for synchronization, with dependency arrays as the honest hazard. The deepest practical distinction is server state versus client state — cache the former with a data library, own the latter yourself — and TypeScript holds the whole equation to its types, so the props and state contracts are checked at the boundary where UI bugs otherwise hide.
Key takeaways
- The UI is a pure function of state: change state, and React makes the screen match — you never touch the DOM imperatively. That single inversion is the entire value.
- Re-rendering is cheap because it diffs a virtual description; only the real DOM change is minimized. A component re-renders on its own state change, a consumed context change, or a parent re-render.
- State flows down as props, events flow up as callbacks. Unidirectional flow is what makes a render explainable; lists need a stable
key(never the array index) for reconciliation to track items. - Effects synchronize with external systems; they are not lifecycle hooks. The dependency array must list every value the effect uses, and those values must be stable — or you get stale closures and infinite loops. Always clean up.
- Server state and client state are different problems: cache server data with a server-state library, and keep
useState/lifting/context for the state the UI truly owns.useMemo/useCallbackare measured optimizations, not defaults.
Connections to other chapters
- TypeScript: Fundamentals (prerequisite): props and state are typed function parameters and values; the interfaces, generics, and unions from that chapter are the language every component contract is written in, and the reason a wrong prop is a compile error rather than a runtime crash.
- Type Systems and Generics (prerequisite): the discriminated unions that model loading/error/success state, and the schema-to-type derivation that makes the API boundary safe, are advanced type-system techniques applied to UI — derived types keep the runtime shape and the static type from drifting apart. That chapter teaches them comparatively, beside the generics and type machinery of the other languages.
- Concurrency and Parallelism Models (extension): data fetching and effects are async work running inside the render cycle. The promises,
await, cancellation, and error propagation underneathfetchand the data library come from that chapter; this one applies them where a component’s lifetime can cut a request short. - Testing and Quality (extension): components are tested the way a user experiences them — query by role and text, interact, assert on what’s on screen — with React Testing Library. Because
UI = f(state)makes rendering deterministic, testing a component is testing a function; that chapter covers the testing discipline across all six languages. - TypeScript: The Node Ecosystem (sibling): the typed client↔︎server boundary has two ends. This chapter consumes the wire safely from the browser; the Node chapter owns the other end — the server framework, the API shape, and where the shared types live.
Further reading
Essential
- React docs — “Thinking in React” (react.dev) — the canonical walkthrough of turning a UI into a component tree and deciding where state belongs; the official statement of the model this chapter teaches.
- React docs — “You Might Not Need an Effect” (react.dev) — the team’s own guide to when
useEffectis the wrong tool, and the antidote to the lifecycle-hook misconception.
Deep dives
- React docs — “Reconciliation” and the writing of the React team on the diffing algorithm and keys — why describing the whole UI on every render is affordable, and what
keyactually buys. - Writing on “UI = f(state)” and unidirectional data flow (the original Flux articulation, and the many restatements since) — the conceptual lineage of the equation at the center of this chapter.
Historical context
- The original virtual DOM rationale from React’s introduction (2013) — why a lightweight in-memory tree, diffed against the last render, was the bet that made “re-render everything” practical.
- Material on reconciliation and Fiber from the React team — the rewrite that made the render phase interruptible, and the modern shape of the cycle described here.