Starting out all 3 written
What React is, which build of it every panel here runs, and how to read the first error you get.
1 What React is, and what it is not
React is a library for describing what a screen should look like, as a function of your data. You write the description; React works out the changes. That is the whole idea, and this lesson shows the objects it is made of.
The build every panel runs. Every panel runs React's development build unless its badge says React prod; lesson 2 shows how and why. The development build is the one that tells you what you did wrong, which is why a course about reading React's messages uses it by default. Every panel's badge says which one it ran, and the output label repeats it.
An element is a plain object
Calling createElement does not touch a screen. It returns an object that says
"a p with the text hi". The panel imports it the way any ES module does;
the Node.js course covers import if it is
new to you.
element.mjs
import { createElement } from 'react';
const el = createElement('p', null, 'hi');
console.log(typeof el);
console.log(String(el.$$typeof));
console.log(el.type);
console.log(Object.keys(el));
console.log(el.props);
check('an element is an object', typeof el, 'object');
check('it is a plain object', Object.getPrototypeOf(el) === Object.prototype, true);
check('its marker is a symbol', String(el.$$typeof), 'Symbol(react.transitional.element)');
check('its type is what we asked for', el.type, 'p');
check('its children are in props', el.props.children, 'hi');
check('the development build freezes it', Object.isFrozen(el) && Object.isFrozen(el.props), true);
What node printed (React 19.2.7, development build), when the build ran it
node element.mjs
object
Symbol(react.transitional.element)
p
[ '$$typeof', 'type', 'key', 'props', '_owner', '_store' ]
{ children: 'hi' }
ok an element is an object
ok it is a plain object
ok its marker is a symbol
ok its type is what we asked for
ok its children are in props
ok the development build freezes it
Nothing here is special: a type, some props, a key. The
marker $$typeof is a symbol so that JSON coming from a server cannot pretend to be
an element. The object is frozen because it is a description, not a thing you change; to change
what is on the screen you describe it again.
A component is a function that returns elements
A component is an ordinary function: it takes an object of props and returns elements. The
tree of elements it returns is still only a description. Turning that description into HTML is
the job of a renderer, and renderToString from react-dom/server is the
smallest one.
greeting.mjs
import { createElement } from 'react';
import { renderToString } from 'react-dom/server';
function Greeting({ name }) {
return createElement('h1', null, 'Hello, ', name);
}
const html = renderToString(createElement(Greeting, { name: 'Ada' }));
console.log(html);
check('the HTML React wrote', html, '<h1>Hello, <!-- -->Ada</h1>');
What node printed (React 19.2.7, development build), when the build ran it
node greeting.mjs
<h1>Hello, <!-- -->Ada</h1> ok the HTML React wrote
The <!-- --> between Hello, and Ada is React's doing. Two
text children next to each other would be read back by a browser as one text node, so React puts
a comment between them to keep them apart. You will meet it again when a page is hydrated, in
lesson 32.
What React is not
React draws the screen and nothing else. It has no router, no way of fetching data, no build step and no opinion about your server. Those come from other tools, and later lessons show where each one plugs in. What it does give you is the same model on the server and in the browser, which is why one version of the library renders HTML here and, in lesson 8, a live DOM.
Which React this is
version.mjs
import * as React from 'react';
console.log(React.version);
console.log(typeof process.env.NODE_ENV);
console.log(typeof React.act);
check('the React in this course', React.version, '19.2.7');
check('a default panel sets no build variable', typeof process.env.NODE_ENV, 'undefined');
check('and so it runs the development build, which has act', typeof React.act, 'function');
What node printed (React 19.2.7, development build), when the build ran it
node version.mjs
19.2.7 undefined function ok the React in this course ok a default panel sets no build variable ok and so it runs the development build, which has act
React decides which of its two builds to load by reading process.env.NODE_ENV
when it is first imported. Nothing sets it in a default panel, so it is undefined,
and React takes the development build. The production build has no act, which is
one of the differences lesson 2 shows side by side.
2 Running React here: Node, the preload, the sandbox, and dev vs production builds
Before the first component, it is worth knowing exactly what runs it.
Every panel in this course is executed by the Node on the machine that built these pages, against
a copy of React that sits in the course's own vendor folder. This lesson shows the two
small files that make that safe and repeatable, and then runs the same code against both builds
of React.
What a panel runs with
A panel is a few files and one command, node App.jsx or similar. Around it the
build adds scaffolding, and the scaffolding is kept out of the panel's own files so that a line
number in an error is the line number you see on the page. It is two files, shown below as the
course's own tools. They are not panels: nothing here is run as a lesson, and no panel ever
contains them.
tools/preload.cjs is loaded into every Node process with
node --require. It compiles .jsx files with the TypeScript that is
vendored next to React, defines the check() function the panels call, records the
console warnings a panel prints so the build can compare them with what the panel declared, gives
DOM panels a window, refuses a server that listens on anything but loopback, and notes which build
of React was loaded. It contains no path: it reads what it needs from six environment variables,
COURSE_VENDOR_ROOT, COURSE_SCRATCH, COURSE_SIDE,
COURSE_DOM, COURSE_ACT and, for production panels, NODE_ENV.
/* The course's preload. Every panel runs with `node --require <this file>`, and every
`node` a terminal panel starts gets it through NODE_OPTIONS. It is the scaffolding
around a panel, kept out of the panel's own files so that line numbers in a stack
trace are the line numbers you see on the page.
It reads everything it needs from the environment and contains no path:
COURSE_VENDOR_ROOT the vendored node_modules (React, TypeScript, happy-dom)
COURSE_SCRATCH the directory the panel lives in
COURSE_SIDE a file the panel's warnings and build records are appended to
COURSE_DOM "1": give the panel a DOM (happy-dom)
COURSE_ACT "1": tell React it is running under act()
NODE_ENV "production" selects React's production build
Eight steps, in this order. */
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const util = require('node:util');
const net = require('node:net');
const nodeModule = require('node:module');
const { fileURLToPath } = require('node:url');
const VENDOR_ROOT = process.env.COURSE_VENDOR_ROOT;
const SCRATCH = process.env.COURSE_SCRATCH;
const SIDE = process.env.COURSE_SIDE;
if (!VENDOR_ROOT) throw new Error('course: COURSE_VENDOR_ROOT is not set');
if (!SCRATCH) throw new Error('course: COURSE_SCRATCH is not set');
if (!SIDE) throw new Error('course: COURSE_SIDE is not set');
/* One line of JSON per record. Synchronous, so a record written just before the
process dies is still there. */
function record(level, text) {
fs.appendFileSync(SIDE, JSON.stringify({ level, text }) + '\n');
}
const inside = (file, root) => file === root || file.startsWith(root + path.sep);
/* 1. Stack traces name the lines of the file as written, not of the compiled code. */
nodeModule.setSourceMapsSupport(true);
/* 2. Two hooks.
resolve: nothing may be loaded from outside the vendored tree or the panel's own
directory. Node's built-ins pass.
load: .jsx files are compiled by the vendored TypeScript, with an inline source
map. Every .jsx file is an ES module. Everything else passes untouched. */
let ts = null;
let jsxOptions = null;
function compiler() {
if (!ts) ts = nodeModule.createRequire(path.join(VENDOR_ROOT, 'noop.js'))('typescript');
if (!jsxOptions) jsxOptions = JSON.parse(fs.readFileSync(path.join(__dirname, 'jsx.json'), 'utf8'));
return ts;
}
function compileJsx(file, source) {
const tsc = compiler();
const name = path.basename(file);
const options = process.env.NODE_ENV === 'production' ? jsxOptions.production : jsxOptions.development;
const result = tsc.transpileModule(source, {
fileName: name,
compilerOptions: options,
reportDiagnostics: true,
});
const diagnostics = result.diagnostics || [];
if (diagnostics.length) {
const first = diagnostics.reduce((a, b) => ((b.start || 0) < (a.start || 0) ? b : a));
const line = first.file ? first.file.getLineAndCharacterOfPosition(first.start || 0).line + 1 : 1;
const text = `${name}:${line}: ${tsc.flattenDiagnosticMessageText(first.messageText, ' ')}`;
/* The record comes first: the verdict fails the panel on it, so a compile error
can never pass as a panel that declared it would die with a SyntaxError. */
record('jsx', text);
const error = new SyntaxError(text);
error.code = 'ERR_COURSE_JSX';
throw error;
}
return result.outputText;
}
nodeModule.registerHooks({
resolve(specifier, context, nextResolve) {
const resolved = nextResolve(specifier, context);
const url = resolved.url;
if (!url || !url.startsWith('file:')) return resolved;
const file = fileURLToPath(url);
if (inside(file, VENDOR_ROOT) || inside(file, SCRATCH)) return resolved;
/* Only names, never the directories they sit in: where this machine keeps node, npm
or the project is nobody's business but its owner's. */
const name = (p) => (path.isAbsolute(p) ? path.basename(p) : p);
const error = new Error(`course: ${name(specifier)} resolved outside the vendored tree, to ${path.basename(file)}`);
error.code = 'ERR_COURSE_UNVENDORED';
throw error;
},
load(url, context, nextLoad) {
if (url.startsWith('file:') && /\.jsx(?:[?#].*)?$/.test(url)) {
const file = fileURLToPath(url.replace(/[?#].*$/, ''));
const source = compileJsx(file, fs.readFileSync(file, 'utf8'));
return { format: 'module', source, shortCircuit: true };
}
return nextLoad(url, context);
},
});
/* 3. check(name, got, want): the assertion every panel uses. It prints "ok name" or
"FAIL name got ..., want ...". Deep equality, so a Map is not {} and NaN is not
null, which a JSON.stringify comparison would claim. */
const { isDeepStrictEqual, inspect } = require("node:util");
globalThis.check = function check(name, got, want) {
const ok = isDeepStrictEqual(got, want);
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " got " + inspect(got) + ", want " + inspect(want)));
};
/* 4. Warnings. console.error and console.warn still print exactly what they printed;
each call is also recorded, so the build knows which warnings a panel produced. */
for (const level of ['error', 'warn']) {
const original = console[level];
console[level] = function (...args) {
record(level, util.format(...args));
return Reflect.apply(original, this, args);
};
}
/* 5. A DOM, when the panel asks for one: a happy-dom window installed as the globals a
browser would have, and an empty region to render into, available as `stage`. It
loads no scripts, styles or frames. */
function defineGlobal(name, value) {
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true, enumerable: false });
}
if (process.env.COURSE_DOM === '1') {
const { Window } = nodeModule.createRequire(path.join(VENDOR_ROOT, 'noop.js'))('happy-dom');
const window = new Window({
url: 'http://localhost/',
settings: {
disableJavaScriptEvaluation: true,
disableJavaScriptFileLoading: true,
disableCSSFileLoading: true,
disableIframePageLoading: true,
},
});
const names = [
'window', 'document', 'navigator', 'Node', 'Element', 'Text', 'MutationObserver',
'Event', 'MouseEvent', 'KeyboardEvent', 'InputEvent', 'FocusEvent', 'SubmitEvent', 'FormData',
...Object.getOwnPropertyNames(window).filter((n) => /^(?:HTML|SVG)\w*Element$/.test(n)),
];
for (const name of names) {
if (name === 'window') defineGlobal(name, window);
else if (window[name] !== undefined) defineGlobal(name, window[name]);
}
defineGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
defineGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
const region = window.document.createElement('div');
region.setAttribute('id', 'stage');
region.setAttribute('role', 'region');
region.setAttribute('aria-label', 'Rendered output');
window.document.body.appendChild(region);
defineGlobal('stage', region);
if (process.env.COURSE_ACT === '1') globalThis.IS_REACT_ACT_ENVIRONMENT = true;
/* 6. Clean up when the process ends through process.exit(). A timer the panel left
running keeps the process alive, so it never reaches here: unmount your roots and
clear your timers. */
process.on('exit', () => {
try { window.happyDOM.abort(); } catch { /* already closed */ }
try { window.close(); } catch { /* already closed */ }
});
}
/* 7. Servers listen on this machine only. The sandbox already stops anything leaving it;
this stops a panel from listening on every interface. A Unix socket path is fine. */
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
const originalListen = net.Server.prototype.listen;
net.Server.prototype.listen = function listen(...args) {
const first = args[0];
let host;
let socketPath = false;
if (first !== null && typeof first === 'object') {
if (typeof first.path === 'string') socketPath = true;
else host = first.host;
} else if (typeof first === 'string' && !/^\d+$/.test(first)) {
socketPath = true;
} else if (typeof args[1] === 'string') {
host = args[1];
}
if (!socketPath && !LOOPBACK.has(host)) {
const error = new Error(
`course: listen on ${host === undefined ? 'every interface' : host} is refused; a panel's server listens on 127.0.0.1`,
);
error.code = 'ERR_COURSE_NOT_LOOPBACK';
throw error;
}
return Reflect.apply(originalListen, this, args);
};
/* 8. Which React build ran. When the process ends, every React file that was loaded
and evaluated is written down as "development: react/cjs/react.development.js" or
"production: ...". The build compares that with the build the panel declared, so
a panel cannot print a "production" badge while running the development build. */
const REACT_BUILD = /\/node_modules\/((?:react|react-dom|scheduler)\/cjs\/[^/]+\.(development|production)\.js)$/;
process.on('exit', () => {
for (const [file, mod] of Object.entries(require.cache)) {
const m = REACT_BUILD.exec(file);
if (m && mod && mod.loaded === true) record('build', `${m[2]}: ${m[1]}`);
}
});
The scaffolding every panel runs with, shown here once as a tool of the course and never as part of a panel. It is the file on disk, byte for byte, so it is the one that ran.
tools/sandbox.sb is a profile for sandbox-exec, the
macOS tool that runs a program with limits. Every panel runs inside it, and so does each
terminal command. The profile leaves a program alone except for the network: it may connect to
and listen on the machine it is on (loopback), and nothing else. Node panels also run under
--permission, so they cannot read or write outside their own folder, the vendored
tree and the preload. Before any panel runs, the build tries three times to reach an address on
the internet from inside the wall and refuses to continue unless all three are blocked; the
result is the last line of the figures on the contents page.
(version 1)
(allow default)
(deny network-outbound)
(allow network-outbound (remote ip "localhost:*"))
(deny network-bind)
(allow network-bind (local ip "localhost:*"))
The profile every panel runs under, shown here once as a tool of the course. Apple has deprecated sandbox-exec but it still ships with macOS, and it is what makes "no network" a fact about the build and not a promise.
Two builds of one library
React ships two builds in one package. The development build checks what you do and writes
warnings; the production build is smaller and faster and says very little. Which one loads is
decided once, when React is first imported, by reading process.env.NODE_ENV. A
panel that does not say otherwise runs the development build, and its badge reads
React dev. A panel declared as production sets NODE_ENV=production, and its
badge reads React prod.
A terminal panel is a real shell session, so the build shows how it gets the same choice. The
next panel runs one small file with node. The preload reaches that node
through the environment, which is why check() exists in a file with no import for
it.
build.cjs
const React = require('react');
const production = process.env.NODE_ENV === 'production';
console.log('the build variable is', process.env.NODE_ENV);
console.log('typeof React.act is', typeof React.act);
check('the variable matches the build', process.env.NODE_ENV, production ? 'production' : undefined);
check('act exists only in the development build', typeof React.act, production ? 'undefined' : 'function');
The terminal (React 19.2.7, development build), as the build ran it
node build.cjs
the build variable is undefined
typeof React.act is function
ok the variable matches the build
ok act exists only in the development build
NODE_OPTIONS loads the course preload into every node this terminal runs, so each one records which React build it loaded.
Now the same file, unchanged, in a terminal declared as production. The first command in the
transcript was not typed by the lesson: the course adds export NODE_ENV=production
to a production terminal, shows it, and runs it like any other command, so what you read is what
ran.
Also on disk, exactly as printed in an earlier panel: build.cjs
The terminal (React 19.2.7, production build), as the build ran it
export NODE_ENV=production node build.cjs the build variable is production typeof React.act is undefined ok the variable matches the build ok act exists only in the development build
NODE_OPTIONS loads the course preload into every node this terminal runs, so each one records which React build it loaded.
The first command was added by the course because this panel runs React's production build; NODE_ENV is in the environment of every command after it.
The file did not change and the answers did, because React chose the other build. The
production build has no act, which is the tool tests use to flush React's work, so a
test must run against development.
The output is the same where it should be
What React writes for the same tree does not depend on the build. The next panel is declared production and renders the greeting from lesson 1.
greeting.mjs
import { createElement } from 'react';
import { renderToString } from 'react-dom/server';
function Greeting({ name }) {
return createElement('h1', null, 'Hello, ', name);
}
const html = renderToString(createElement(Greeting, { name: 'Ada' }));
console.log(html);
console.log(process.env.NODE_ENV);
check('production wrote the same HTML as development', html, '<h1>Hello, <!-- -->Ada</h1>');
check('and this panel really ran the production build', process.env.NODE_ENV, 'production');
What node printed (React 19.2.7, production build), when the build ran it
NODE_ENV=production node greeting.mjs
<h1>Hello, <!-- -->Ada</h1> production ok production wrote the same HTML as development ok and this panel really ran the production build
What changes is everything around the output: the warnings, the error messages and the speed. Later lessons show warnings that exist only in development, and the error catalogue shows what each message looks like.
On your own machine
You will not copy any of the above. On your own machine React lives in a project, built by a
tool such as Vite, and npm installs it. The Node.js course covers
running code and
npm, which is all the machinery a React project sits on.
npm create vite@latest my-app -- --template react
cd my-app
npm install
npm run dev
needs Vite and the npm registry, which this offline course never contacts. The panels above run the same React without them.
3 Your first component, and reading React's errors
A component is a function that returns elements. You can build a whole application from nothing else. This lesson writes one, with JSX, renders it, and then breaks it on purpose to read what React says.
A component with props
The file below is App.jsx. The tags look like HTML, and they are not: lesson 4
shows that each one is a function call. For now, read it as a description of the page. A
component's name starts with a capital letter, which is how React tells <Badge />
(your function) from <p> (an HTML element). Whatever you write as attributes
arrives as one object, the props.
App.jsx
import { renderToString } from 'react-dom/server';
function Badge({ name, role }) {
return (
<p>
<strong>{name}</strong> is a {role}
</p>
);
}
const html = renderToString(<Badge name="Ada" role="mathematician" />);
console.log(html);
check('the HTML React wrote', html, '<p><strong>Ada</strong> is a <!-- -->mathematician</p>');
check('the same component with other props', renderToString(<Badge name="Grace" role="admiral" />),
'<p><strong>Grace</strong> is a <!-- -->admiral</p>');
What node printed (React 19.2.7, development build), when the build ran it
node App.jsx
<p><strong>Ada</strong> is a <!-- -->mathematician</p> ok the HTML React wrote ok the same component with other props
The curly braces are where JavaScript goes: {name} is the value of the
name prop, not the word. The same function produced both results, which is the
point of a component: one description, many uses.
Reading an error
You are going to see this within the hour, so here it is on purpose. Profile reads
user.profile.name, and the user it is given has a name but no profile. The panel is
declared to die with a TypeError, and the build checks that it did. Read it in this
order.
main.jsx
import { renderToString } from 'react-dom/server';
// A small team page.
function Avatar({ src, alt }) {
return <img src={src} alt={alt} width="40" height="40" />;
}
function Header({ title }) {
return (
<header>
<h1>{title}</h1>
</header>
);
}
// Shows one person.
function Profile({ user }) {
return <p>{user.profile.name}</p>;
}
function Page() {
return (
<main>
<Header title="Team" />
<Profile user={{ name: 'Ada' }} />
</main>
);
}
try {
renderToString(<Page />);
} catch (e) {
const frames = e.stack.split('\n').filter((line) => line.startsWith(' at '));
const line = Number(/main\.jsx:(\d+)/.exec(e.stack)[1]);
check('the error points at line 18 as shown', line, 18);
check('and the first frame is the component', /^ {4}at Profile /.test(frames[0]), true);
throw e;
}
What node printed as it died (React 19.2.7, development build), when the build ran it
node main.jsx
ok the error points at line 18 as shown
ok and the first frame is the component
/app/main.jsx:18
return <p>{user.profile.name}</p>;
^
TypeError: Cannot read properties of undefined (reading 'name')
at Profile (/app/main.jsx:18:27)
at Object.react_stack_bottom_frame (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:9808:18)
at renderWithHooks (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:5062:19)
at renderElement (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:5497:23)
at retryNode (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6428:21)
at renderNodeDestructive (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6367:11)
at renderNode (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6905:18)
at renderChildrenArray (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6674:11)
at retryNode (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6441:13)
at renderNodeDestructive (/app/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6367:11)
Node.js v26.7.0
- The first line is a place.
/app/main.jsx:18, then that line of your source with a^under the exact spot. The file is the one above, and the line number is the line number you can count in it: the build compiles the JSX with source maps on, so the compiled code that actually ran never shows. The panel checks that, so it is not a claim. - The next line is the problem.
TypeError: Cannot read properties of undefined (reading 'name'). Something tried to readnamefromundefined. Here that isuser.profile, which does not exist. - The first
atline is where it happened.at Profile (/app/main.jsx:18:27): your component, line 18, column 27. The panel checks that too. - Everything after that is React. The frames under
/app/node_modules/react-domare the renderer walking the tree. Stop reading when you reach them. The mistake is almost never there.
The error says where it was noticed. The mistake is in the data one level up:
Page gave Profile a user without a profile. That gap, between where a
program noticed a problem and where it was caused, is what most debugging is.
Reading a warning
Not everything React says kills the render. A warning is printed and the program carries on, and
in a browser it goes to the console. This one is about HTML: a <div> may not sit
inside a <p>. The panel declares that it will warn, and the build checks that
the warning appeared and nothing else did.
App.jsx
import { act } from 'react';
import { createRoot } from 'react-dom/client';
function Card() {
return (
<p>
<div>inside</div>
</p>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Card />));
check('it rendered anyway', stage.innerHTML, '<p><div>inside</div></p>');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node App.jsx
In HTML, <div> cannot be a descendant of <p>. This will cause a hydration error. <Card> > <p> > <div> ok it rendered anyway
A warning is a sentence, then a component stack: the part of the tree around
the mistake, outermost first, with > marking the path to the offending element.
Read it the way you read the frames above. Find the component you wrote, here
<Card>, and the element inside it that React is pointing at. React adds that
this will cause a hydration error because an HTML parser would not keep that nesting; lesson 32
is about hydration. On the client React built exactly what the JSX describes, and the check
confirms it.