Writing React 19 from scratch
Updated 31 July 2025
Browsers begin life as parsers
Open a new tab and nothing really happens—until something arrives. What arrives is always a string of bytes. Give browsers this string and they turn it into something else: a tree of nodes. If you’ve ever written a compiler this is familiar: <h1>
is a token,<div>
is another and so on. Tokens carry meaning (e.g.</h1>
closes a branch). The rules live in the HTML spec[1]; the result is the DOM.
3c 68 74 6d 6c 3e 0a 20 20 3c 68 65 61 64 3e 3c 74 69 74 6c 65 3e 50 61 72 73 65 72 20 44 65 6d 6f 3c 2f 74 69 74 6c 65 3e 3c 2f 68 65 61 64 3e 0a 20 20 3c 62 6f 64 79 3e 0a 20 20 20 20 3c 68 31 3e 48 65 6c 6c 6f 2c 20 70 61 72 73 65 72 21 3c 2f 68 31 3e 0a 20 20 20 20 3c 62 75 74 74 6f 6e 20 69 64 3d 22 73 61 76 65 22 3e 53 61 76 65 3c 2f 62 75 74 74 6f 6e 3e 0a 20 20 20 20 3c 70 20 63 6c 61 73 73 3d 22 6e 6f 74 65 22 3e 50 61 72 73 69 6e 67 20 69 73 20 62 65 6c 69 65 76 69 6e 67 2e 3c 2f 70 3e 0a 20 20 3c 2f 62 6f 64 79 3e 0a 3c 2f 68 74 6d 6c 3e
Listing A – raw bytes on the wire (hex encoded) that expand into a real DOM tree.
These conventions are nothing but a handshake: the server promises to speak in tags (tokens) and the client agrees to parse them.
Figure 0 – the browser tokenises and builds a tree.
1<html>
2 <head><title>Parser Demo</title></head>
3 <body>
4 <h1>Hello, parser!</h1>
5 <button id="save">Save</button>
6 <p class="note">Parsing is believing.</p>
7 </body>
8</html>
Listing B – bytes the browser decodes.
They could have agreed on other grammars—S‑expressions or YAML—but the result is always similar: a nested data structure. In fact, Any syntax would work (as long as both sides respect the same grammar), but HTML’s angle‑bracket tokens won and became the contract. Understanding this parsing step is the first key to seeing why React—an abstraction over the DOM—exists at all.
YAML version
1html:
2 head:
3 title: Parser Demo
4 body:
5 - h1: Hello, parser!
6 - button:
7 id: save
8 text: Save
9 - p:
10 class: note
11 text: Parsing is believing.
JSON markup version
1{
2 "html": {
3 "head": { "title": "Parser Demo" },
4 "body": [
5 { "h1": "Hello, parser!" },
6 { "button": { "id": "save", "children": ["Save"] } },
7 { "p": { "class": "note", "children": ["Parsing is believing."] } }
8 ]
9 }
10}
Rendered result
Manipulating the DOM with vanilla JS
Once the browser has parsed your HTML—essentially a structured text file—into a DOM tree, the next step is to modify that tree and its nodes. For that we need a programming language. JavaScript is the de facto language of the web and runs in every modern browser. It provides the instructions to traverse, inspect and change the DOM. As a Turing‑complete language it can, in theory, simulate any algorithm, so aside from practical limits like memory you have enormous freedom in what you can compute and build with it.
The global document
object is your entry point into the DOM[2]. It represents the entire page and exposes methods likequerySelector
and createElement
to inspect and modify the DOM. After the DOM is built you can poke that tree with JavaScript: call document.createElement
to grow a new branch, appendChild
to graft it on orremove()
to amputate. It feels powerful—until you have to remember every incision you made five minutes ago.
We manipulate the DOM to bring pages to life. Scripts update text or styles when data changes, respond to clicks, keyboard input or other events, show and hide elements, and generally make the UI interactive. The DOM API allows your code to change a document’s structure, style or content and attach event handlers so user actions trigger logic[3]. Without such mutations, a web page would remain static and unresponsive.
1// vanilla JS DOM manipulation
2document.querySelector("h1").textContent = "Hello DOM!";
3const btn = document.createElement("button");
4btn.textContent = "Click me";
5document.body.appendChild(btn);
Listing B – three direct DOM mutations: edit text, create a node, attach it.
This diagram shows how the <h1>
node is updated and a new<button>
node is appended to the <body>
.
Each line above is an imperative command: “Do this to that node right now.” The browser complies immediately, repainting after every change. That immediacy is a double‑edged sword—fine for demos, treacherous for apps.
Consider the classic counter widget. You store the number in a global variable and bump it on every click:
1let count = 0;
2button.addEventListener("click", () => {
3 count++;
4 counterSpan.textContent = count; // keep DOM in sync (hopefully)
5});
Clicking the button updates the <span>
displaying the count.
Interactive preview – click the button to increment the count and see the DOM update.
Leave out that last line just once—say, inside an error path—and the UI shows “3” while count
is actually 4. Side‑effects accumulate and state (the remembered values your program uses to compute results) drifts[4], and eventually a user wonders why the “Save” button stops working after the tenth click. Imperative code demands you remember every prior mutation; the browser will not remind you.
Developers in the 2000s reached for helper libraries—most famously jQuery—to make these incisions shorter and more cross‑browser. As we’ll see next, jQuery hid the pain but didn’t change the underlying model: lists of commands, each with potential for drift. React’s big idea will be to flip that model on its head.
Enter jQuery & the imperative mindset
The mid‑2000s web no longer looked like a collection of linked documents. Apps such as Gmail (2004) or Flickr’s uploader re‑painted parts of the page without reloads, fetched data via the freshly coined “AJAX” technique and responded to keyboard shortcuts like desktop software. Each feature meant more DOM nodes,more event handlers and more browser quirks to wrestle with.
Vanilla JavaScript could do the job, but the ergonomics were brutal: five lines to locate a node, patch its text, then wire a click handleronly if the browser supported addEventListener
. Teams sprinkled code with if (isIE)
branches, shipped separate stylesheets for IE6, and spent hours diffing user‑agent strings instead of shipping features.
jQuery arrived in 2006 as a unified façade. One tiny minified file hid the incompatibilities, offered a terse CSS‑selector‑like API, normalised events, handled Ajax and even bundled a plugin ecosystem. Crucially, the mental burden shrank: “Write less, do more” wasn’t a slogan—it felt like a fact.
But both vanilla and jQuery share the same imperative DNA: they issue step‑by‑step commands that mutate the DOM immediately. As the number of states, handlers and branches grows, keeping the DOM, JS variables and business‑state in sync becomes a full‑time bug source. This is the scalability cliff we’ll quantify in the next chapter— “Why touching the DOM is costly”.
Vanilla JS
1// Vanilla JavaScript “Hello DOM”
2const h1 = document.querySelector("h1");
3h1.textContent = "Hello, DOM!";
4
5const btn = document.createElement("button");
6btn.textContent = "Click me";
7document.body.appendChild(btn);
jQuery
1// jQuery “Hello DOM”
2$("h1").text("Hello, DOM!");
3$("<button>Click me</button>").appendTo("body");
Listing D – jQuery condenses four vanilla lines into two, but both still “command” the DOM.
Vanilla JS counter
1let count = 0;
2const counterEl = document.getElementById("counter");
3document.getElementById("add").addEventListener("click", () => {
4 count += 1;
5 counterEl.textContent = count; // sync DOM every click
6});
jQuery counter
1let count = 0;
2const $counter = $("#counter");
3$("#add").on("click", () => {
4 count++;
5 $counter.text(count); // keep DOM in sync
6});
Listing E – shorter syntax, same imperative upkeep: every click mutates JS state and DOM manually.
Vanilla JS todo update
1const list = document.getElementById("todo");
2list.classList.add("list");
3
4// style odd <li> elements
5[...list.children].forEach((li, idx) => {
6 if (idx % 2) li.style.background = "#f9f9f9";
7});
8
9// add new item
10const li = document.createElement("li");
11li.textContent = "New task";
12list.appendChild(li);
jQuery method‑chaining
1$("#todo")
2 .addClass("list") // styling
3 .find("li:odd") // selector engine
4 .css("background", "#f9f9f9")
5 .end()
6 .append("<li>New task</li>");
Listing F – chaining looks declarative, yet each call is an immediate DOM mutation. Re‑ordering two steps can silently break the UI.
jQuery made imperative DOM code bearable, not scalable. When every interaction requires manual bookkeeping, a medium‑sized app quickly amasses hundreds of scattered updates. React’s leap will be to swap imperative commands for a single declarative description—a topic we explore right after we measure the real cost of “touching” the DOM.
Why touching the DOM is costly
In the late 1990s a “heavy” web page had maybe fifty nodes—some <table>
rows, a handful of images, and a sprinkling of <font>
tags. A single innerHTML
swap was cheap because there was almost nothing to recalculate.
Fast‑forward to 2007 and Gmail, Google Maps and Facebook were running in the tab next to your email. They streamed new data every few seconds, animated sidebars, and ran rich text editors in place. Suddenly one page could host thousands of DOM nodes, each with styles, transitions and event listeners. Anything that forced the browser to re‑measure positions had to walk a tree the size of a small novel.
The hardware curve didn’t bail us out: laptops of the day shipped with 1 GB of RAM and phone CPUs barely touched 500 MHz. Developers learned a painful truth: cost scales with surface area. Twice the nodes means twice the style rules to match and twice the layout boxes to compute—multiplied by 60 frames per second if you’re animating.
Worse, the cost is incremental. Each call to textContent
or style.width =
doesn’t just tweak a property; it invalidates caches, dirties style data and queues a layout pass. Do that inside a loop or inside a scroll handler and you get jank: the UI stutters because the main thread is busy re‑flowing pixels you can’t even see.
So when people say “the DOM is slow” they don’t mean the API is sluggish—they mean naïve mutation patterns amplify hidden work. The larger and more interactive the page, the higher the hidden bill.
Updating a DOM node looks like a single operation, but under the hood it punches a five‑stage pipeline: JavaScript runs, styles recalibrate, layout recalculates where every pixel goes, paint splashes color into new bitmaps, and the compositor finally stitches layers together. One stray textContent
change can ripple all the way to GPU memory[5].
Fig 1 – the render pipeline every mutation can trigger.
The bill arrives per mutation. Add 1 000 list items the naïve way and you pay for 1 000 layouts:
1// ❌ 1 000 reflows – one per iteration
2for (let i = 0; i < 1000; i++) {
3 const li = document.createElement("li");
4 li.textContent = `Item ${i}`;
5 document.body.appendChild(li); // layout + paint each time
6}
Listing F – one layout per appendChild
→ jank.
Batch first, mutate once, and the cost collapses:
1// ✅ 1 reflow – batch with DocumentFragment
2const frag = document.createDocumentFragment();
3for (let i = 0; i < 1000; i++) {
4 const li = document.createElement("li");
5 li.textContent = `Item ${i}`;
6 frag.appendChild(li); // nothing in DOM yet
7}
8document.body.appendChild(frag); // single layout + paint
Reads can hurt too. Ask for offsetWidth
right after you tweak styles and the engine must flush layout synchronously—a pattern called layout thrashing.
1div.style.width = "200px"; // mutate (invalidates layout)
2const w = div.offsetWidth; // read (forces sync layout)
3div.style.width = "300px"; // mutate again
4const w2 = div.offsetWidth; // another forced layout
5// Four full pipeline passes – jank city
Fig 2 – mutate → forced read → mutate … each jump flushes the pipeline.
jQuery shortened the syntax but couldn’t shield you from a thousand tiny reflows. We need a staging area—a place to describe changes, batch them and commit the minimal diff. To get there we first need to learn how React lets us describe the UI declaratively with JSX; once we’ve covered component basics we’ll return to the idea of avirtual DOM and see how it buffers and batches updates.
JSX, createElement
, Declarative vs imperative
jQuery let us command the DOM with fewer keystrokes, but we were still writing a recipe of mutations. React’s leap of faith is to flip the mental model: **describe the UI you want, given the current state, and let a library figure out the mutations for you**. Two pieces enable that leap—createElement
and its nicer face, JSX.
Fig 3 – JSX is sugar for createElement
; both build a lightweight “virtual” tree before the real DOM is touched.
Under the hood createElement
is startlingly small: it returns a plain JavaScript object – no browser APIs, no reflows, just data.
1// No JSX – explicit calls
2const el = React.createElement(
3 "button",
4 { className: "primary", onClick: handleSave },
5 "Save"
6);
Writing that by hand gets old fast, so React borrowed angle‑bracket syntax from HTML and let Babel turn it back into those objects.
1// Same UI, but declarative JSX
2<button className="primary" onClick={handleSave}>
3 Save
4</button>;
Listing J – easier to read, still pure data.
1// Babel output (simplified)
2const el = /*#__PURE__*/ React.createElement(
3 "button",
4 { className: "primary", onClick: handleSave },
5 "Save"
6);
Listing K – Babel output (simplified).
Notice what’s missing: appendChild
, textContent
, any mention of the DOM at all. JSX is declarative: “Here’s what the UI should be.” React will reconcile that description with the DOMlater, in bulk, after you finish rendering.
Declarative code scales because each render starts from scratch in memory. Take a counter component:
1function Counter() {
2 const [count, set] = React.useState(0);
3 return (
4 <button onClick={() => set(count + 1)}>
5 Count {count}
6 </button>
7 );
8}
Every click sets new state
, React reruns Counter()
, produces a fresh tree, and compares it to the previous one. You no longer remember prior incisions; React’s diff algorithm handles the bloody details. That diff lives in the virtual DOM, whose purpose we’ll explore after we cover component state and lifecycle.
Props & state
React components juggle two data sources. Propsflow into a component from its parent and are immutable.State lives inside a component and can change – triggering a new render. Everything visible on screen is a pure function of(props, state)
.
Fig 9 – props cascade downward; only callbacks travel up.
A canonical pattern: parent owns state, passes value + callback to a child. The child never mutates data directly; it requests changes by calling the callback.
1function Parent() {
2 const [count, set] = React.useState(0);
3 return (
4 <Counter
5 label="Clicks"
6 value={count} // ✅ prop (read‑only by child)
7 onIncrement={() => set(count + 1)} // ✅ callback prop
8 />
9 );
10}
11
12function Counter({ label, value, onIncrement }) {
13 return (
14 <button onClick={onIncrement}>
15 {label}: {value}
16 </button>
17 );
18}
The button displays value
(a prop) yet changes it by invoking onIncrement
. The update travels up, setState
fires, React re‑renders Parent
, and the new prop cascades back down.
Fig 10 – one click’s round‑trip through the data flow.
Local state is for information the component alone owns – e.g. UI toggles, transient form input:
1function Toggle() {
2 const [on, set] = React.useState(false); // ✅ local state
3 return (
4 <button onClick={() => set(!on)}>
5 {on ? "ON" : "OFF"}
6 </button>
7 );
8}
A golden rule: never mutate props. Doing so violates the parent’s expectations and destroys the guarantee that render is pure.
1// ⚠️ anti‑pattern: child mutates prop object
2function List({ items }) {
3 items.push("⚠️"); // bad – breaks parent's assumptions
4 return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
5}
Listing O – anti‑pattern: mutating a prop breaks referential integrity and can cause phantom re‑renders.
In practice, most state lives at the lowest shared ancestor that needs the data – a principle nicknamed “lift state up.” React’s one‑way data flow keeps mental overhead low: to understand any component, trace props in, local state inside, and callbacks out. No hidden observers, no two‑way bindings.
With a solid grasp of props & state we can now explore how React orchestrates their lifecycle – mounting, updating, and unmounting – while keeping your effects predictable.
Lifecycle
Every component lives three main moments: mount (birth),update (growth), and unmount (death). React’s mission is to make these transitions predictable and side‑effect free.
Fig 11 – the life‑cycle in three acts.
Class lifecycles (legacy)
Class components expose explicit callbacks like componentDidMount
and componentWillUnmount
. They still work, but hooks ± function components are simpler:
1class Clock extends React.Component {
2 state = { now: new Date() };
3
4 componentDidMount() {
5 this.id = setInterval(
6 () => this.setState({ now: new Date() }),
7 1000
8 );
9 }
10 componentWillUnmount() {
11 clearInterval(this.id);
12 }
13 render() {
14 return <span>{this.state.now.toLocaleTimeString()}</span>;
15 }
16}
Functional lifecycles (hooks)
useEffect
merges didMount + didUpdate +willUnmount into one API:
1function Clock() {
2 const [now, setNow] = React.useState(() => new Date());
3
4 React.useEffect(() => {
5 const id = setInterval(() => setNow(new Date()), 1000);
6 return () => clearInterval(id); // cleanup on unmount
7 }, []); // run once (mount)
8
9 return <span>{now.toLocaleTimeString()}</span>;
10}
The effect runs after commit, and its return value handles cleanup. Supply a dependency array to control when updates fire. An empty array []
means “mount once.”
Layout vs passive effects
React splits effects into two buckets: useLayoutEffect
runssynchronously after DOM mutations but before the browser paints (good for measuring layout). useEffect
waits untilafter paint, keeping the main thread free for user input.
Fig 12 – order of operations inside the commit phase.
1// Commit phase order (simplified):
2commitRoot() {
3 // 1️⃣ Mutate DOM
4 applyDomChanges();
5
6 // 2️⃣ Layout effects – sync, block paint
7 runLayoutEffects(); // useLayoutEffect callbacks
8
9 // 3️⃣ Browser paints
10 requestAnimationFrame(() => {
11 // 4️⃣ Passive effects – async, after paint
12 runPassiveEffects(); // useEffect callbacks
13 });
14}
Rules of thumb
- Read / measure in
useLayoutEffect
;fetch / subscribe inuseEffect
. - Always declare dependencies; React’s lint‑rule helps you stay honest.
- Cleanup everything you created: event listeners, timers, observers.
Mastering lifecycle APIs prevents memory leaks, errant reflows, and UI flicker. Equipped with this, we can move to how React turns browser events into predictable state changes via its synthetic event system.