Emmanuel Akinfulubi

JSX all 4 written

JSX is function calls: what it compiles to, how props and children travel, and rendering conditionally and in lists.

4 JSX is function calls: what it compiles to, expressions and fragments

JSX looks like HTML inside JavaScript, and it is neither. Each tag is a function call, and what the call returns is the plain object from lesson 1. This lesson runs the compiler that does the translation, so you can read what your tags become.

What the compiler writes

A browser cannot read JSX and neither can Node, so a tool compiles it first. On your own machine that tool is part of Vite or Next.js. Here it is TypeScript, vendored next to React, and the options it is given are in jsx.json, shown below. The panel imports that file the way any ES module imports JSON; the Node.js course covers import if it is new to you. It compiles one line of JSX twice, once with the development options and once with the production options, and prints the JavaScript each writes.

One line of JSX, compiled twice

ran at build time · server (no DOM) · React dev

jsx.json

{
  "development": {
    "jsx": "react-jsxdev",
    "module": "esnext",
    "target": "esnext",
    "inlineSourceMap": true
  },
  "production": {
    "jsx": "react-jsx",
    "module": "esnext",
    "target": "esnext",
    "inlineSourceMap": true
  }
}

compile.mjs

import ts from 'typescript';
import options from './jsx.json' with { type: 'json' };

const source = 'const el = <h1 className="big">Hello, {name}</h1>;';

function compile(settings) {
  return ts.transpileModule(source, {
    fileName: 'App.jsx',
    compilerOptions: { ...settings, inlineSourceMap: false },
  }).outputText;
}

const dev = compile(options.development);
console.log(dev);

const prod = compile(options.production);
console.log(prod);

check('development: it calls jsxDEV', dev.includes('_jsxDEV("h1"'), true);
check('development: from react/jsx-dev-runtime', dev.includes('from "react/jsx-dev-runtime"'), true);
check('development: it records the file name', dev.includes('const _jsxFileName = "App.jsx"'), true);
check('production: it calls _jsx', prod.includes('_jsxs("h1"'), true);
check('production: from react/jsx-runtime', prod.includes('from "react/jsx-runtime"'), true);
check('production: no file name is recorded', prod.includes('fileName'), false);

What node printed (React 19.2.7, development build), when the build ran it

node compile.mjs

import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
const _jsxFileName = "App.jsx";
const el = _jsxDEV("h1", { className: "big", children: ["Hello, ", name] }, void 0, true, { fileName: _jsxFileName, lineNumber: 1, columnNumber: 12 }, this);

import { jsxs as _jsxs } from "react/jsx-runtime";
const el = _jsxs("h1", { className: "big", children: ["Hello, ", name] });

ok   development: it calls jsxDEV
ok   development: from react/jsx-dev-runtime
ok   development: it records the file name
ok   production: it calls _jsx
ok   production: from react/jsx-runtime
ok   production: no file name is recorded

Two things to read in that output. First, the tag became a call: <h1 className="big"> is jsx("h1", { className: "big", children: [...] }), with the attributes as the props and the content between the tags as children. Second, the two builds differ in exactly the way lesson 2 said: the development call carries the file name, line and column, which is how React can say App.jsx:1 in an error, and the production call does not. The import at the top is the "automatic runtime": you never write import React for JSX to work, because the compiler adds the import it needs.

JSX and createElement are the same thing

The call the compiler writes has a hand-written twin, createElement. The next panel builds the same tree both ways and asks React to write the HTML for each. It uses renderToStaticMarkup, which writes plain HTML without the comment markers that renderToString adds between text nodes (lesson 30 is about those).

JSX against createElement

ran at build time · server (no DOM) · React dev

twin.jsx

import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

const name = 'Ada';

const withJsx = (
  <p className="note">
    Hello, <strong>{name}</strong>
  </p>
);

const withCalls = createElement(
  'p',
  { className: 'note' },
  'Hello, ',
  createElement('strong', null, name),
);

console.log(renderToStaticMarkup(withJsx));
console.log(renderToStaticMarkup(withCalls));

check('JSX writes the expected HTML', renderToStaticMarkup(withJsx), '<p class="note">Hello, <strong>Ada</strong></p>');
check('the createElement calls write the same', renderToStaticMarkup(withCalls), renderToStaticMarkup(withJsx));

What node printed (React 19.2.7, development build), when the build ran it

node twin.jsx

<p class="note">Hello, <strong>Ada</strong></p>
<p class="note">Hello, <strong>Ada</strong></p>
ok   JSX writes the expected HTML
ok   the createElement calls write the same

JSX is the nicer way to write the tree, and that is all it is. It also explains the rules you will trip over: a component must return one value, because a function returns one value; and a name starting with a capital letter is passed to the call as the function itself, while a lower-case name is passed as a string.

Holes, attributes and escaping

Curly braces are a hole where any JavaScript expression goes: a variable, a call, a ternary, an arithmetic sum. A statement such as if does not go there. Attributes are props, so they use the DOM's names where HTML's differ: className for class, htmlFor for for. style takes an object, which is why it has two pairs of braces: one for the hole, one for the object. And text you put in a hole is text, never markup.

Holes, attributes and escaping

ran at build time · server (no DOM) · React dev

holes.jsx

import { renderToStaticMarkup } from 'react-dom/server';

const price = 4;
const html = (tag) => renderToStaticMarkup(tag);

console.log(html(<p>Total: {price * 3} for {'3 items'}</p>));
console.log(html(<label htmlFor="email" className="field">Email</label>));
console.log(html(<span style={{ fontSize: 12, color: 'red' }}>small</span>));
console.log(html(<p>{'<b>not bold</b>'}</p>));
console.log(html(<p>line<br />break</p>));

check('an expression in a hole', html(<p>Total: {price * 3} for {'3 items'}</p>), '<p>Total: 12 for 3 items</p>');
check('htmlFor and className', html(<label htmlFor="email" className="field">Email</label>),
  '<label for="email" class="field">Email</label>');
check('style takes an object', html(<span style={{ fontSize: 12, color: 'red' }}>small</span>),
  '<span style="font-size:12px;color:red">small</span>');
check('text is escaped, not parsed', html(<p>{'<b>not bold</b>'}</p>), '<p>&lt;b&gt;not bold&lt;/b&gt;</p>');
check('a tag with no children closes itself', html(<p>line<br />break</p>), '<p>line<br/>break</p>');

What node printed (React 19.2.7, development build), when the build ran it

node holes.jsx

<p>Total: 12 for 3 items</p>
<label for="email" class="field">Email</label>
<span style="font-size:12px;color:red">small</span>
<p>&lt;b&gt;not bold&lt;/b&gt;</p>
<p>line<br/>break</p>
ok   an expression in a hole
ok   htmlFor and className
ok   style takes an object
ok   text is escaped, not parsed
ok   a tag with no children closes itself

The escaping is the quiet safety feature. A user's name that contains <script> comes out as text, because React writes it as &lt;script&gt;. Inserting raw HTML takes a prop that is named dangerouslySetInnerHTML on purpose.

Fragments

A component returns one value, but sometimes you want several siblings with no wrapper element around them. A fragment, written <>...</>, groups them and renders nothing of its own. When you need to give it a key (lesson 7) you write <Fragment key=...> instead.

A fragment

ran at build time · server (no DOM) · React dev

fragment.jsx

import { Fragment } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

function Terms() {
  return (
    <>
      <dt>JSX</dt>
      <dd>function calls</dd>
    </>
  );
}

const html = renderToStaticMarkup(<dl><Terms /></dl>);
console.log(html);

check('a fragment adds no wrapper', html, '<dl><dt>JSX</dt><dd>function calls</dd></dl>');
check('Fragment is the same thing spelled out',
  renderToStaticMarkup(<Fragment><b>a</b><i>b</i></Fragment>), '<b>a</b><i>b</i>');

What node printed (React 19.2.7, development build), when the build ran it

node fragment.jsx

<dl><dt>JSX</dt><dd>function calls</dd></dl>
ok   a fragment adds no wrapper
ok   Fragment is the same thing spelled out

An element is a plain object

The value of a JSX expression is the object lesson 1 printed. It has a type, which is a string for an HTML element and the function itself for a component, and props, with the children inside.

What a tag evaluates to

ran at build time · server (no DOM) · React dev

element.jsx

const el = <p className="note">hi</p>;

function Greeting() {
  return null;
}
const component = <Greeting name="Ada" />;

console.log(el.type);
console.log(el.props);
console.log(component.type === Greeting);

check('a tag is a string type', el.type, 'p');
check('children are in the props', el.props.children, 'hi');
check('attributes are in the props', el.props.className, 'note');
check('a component element holds the function', component.type === Greeting, true);
check('and its props', component.props.name, 'Ada');
check('the development build freezes the props', Object.isFrozen(el.props), true);

What node printed (React 19.2.7, development build), when the build ran it

node element.jsx

p
{ className: 'note', children: 'hi' }
true
ok   a tag is a string type
ok   children are in the props
ok   attributes are in the props
ok   a component element holds the function
ok   and its props
ok   the development build freezes the props

Nothing has been drawn yet, and nothing was called: Greeting was never run. An element is only a description. The next lessons are about the information you put in it, and how you vary it.

  • JSX compiles to jsx calls; the development build adds the file and line.
  • { } holds an expression. Attributes are props: className, htmlFor, and style as an object.
  • Text in a hole is escaped. A fragment groups siblings without a wrapper.
  • A tag evaluates to a plain object with a type and props.

5 Props and children

A component with no inputs would show the same thing forever. Props are its inputs: the attributes you write on the tag, gathered into one object and handed to the function. That is all a prop is, and this lesson shows each piece of it working.

Props are one object

When React calls Badge it passes one argument. Writing function Badge({ name, role }) is ordinary destructuring of that object, covered in the Node.js course. A default goes in the pattern: size = 'small' applies when the prop was not passed. The text between the tags arrives as the special prop children.

Props, defaults and children

ran at build time · server (no DOM) · React dev

props.jsx

import { renderToStaticMarkup } from 'react-dom/server';

function Badge(props) {
  return <p>{Object.keys(props).join(',')}</p>;
}

function Tag({ label, size = 'small', children }) {
  return <span className={size}>{label}: {children}</span>;
}

const html = (el) => renderToStaticMarkup(el);

console.log(html(<Badge name="Ada" role="mathematician" />));
console.log(html(<Tag label="a">one</Tag>));
console.log(html(<Tag label="b" size="large">two</Tag>));
console.log(html(<Tag label="c" size={undefined}>three</Tag>));
console.log(html(<Tag label="d" size={null}>four</Tag>));

check('props are an object of what was written', html(<Badge name="Ada" role="mathematician" />), '<p>name,role</p>');
check('a default fills a missing prop', html(<Tag label="a">one</Tag>), '<span class="small">a: one</span>');
check('a passed prop wins', html(<Tag label="b" size="large">two</Tag>), '<span class="large">b: two</span>');
check('undefined counts as missing', html(<Tag label="c" size={undefined}>three</Tag>), '<span class="small">c: three</span>');
check('null does not', html(<Tag label="d" size={null}>four</Tag>), '<span>d: four</span>');

What node printed (React 19.2.7, development build), when the build ran it

node props.jsx

<p>name,role</p>
<span class="small">a: one</span>
<span class="large">b: two</span>
<span class="small">c: three</span>
<span>d: four</span>
ok   props are an object of what was written
ok   a default fills a missing prop
ok   a passed prop wins
ok   undefined counts as missing
ok   null does not

The last two lines are the one surprise. A default applies to undefined, which is what a missing prop is, and not to null, which is a value somebody chose to pass.

Children can be anything

children is whatever sits between the tags: text, an element, several elements, even a function. One child arrives as itself and several arrive as an array. A component that renders {children} inside its own markup is a wrapper, which is how a card, a layout or a button with an icon is built.

Children

ran at build time · server (no DOM) · React dev

children.jsx

import { renderToStaticMarkup } from 'react-dom/server';

function Count({ children }) {
  return <p>{Array.isArray(children) ? children.length + ' children' : 'one child'}</p>;
}

function Card({ title, children }) {
  return (
    <section className="card">
      <h2>{title}</h2>
      {children}
    </section>
  );
}

console.log(renderToStaticMarkup(<Count><b>a</b></Count>));
console.log(renderToStaticMarkup(<Count><b>a</b><i>b</i></Count>));
console.log(renderToStaticMarkup(<Card title="Notes"><p>First</p><p>Second</p></Card>));

check('one child is not wrapped in an array', renderToStaticMarkup(<Count><b>a</b></Count>), '<p>one child</p>');
check('two children are an array', renderToStaticMarkup(<Count><b>a</b><i>b</i></Count>), '<p>2 children</p>');
check('the wrapper puts the children inside itself',
  renderToStaticMarkup(<Card title="Notes"><p>First</p><p>Second</p></Card>),
  '<section class="card"><h2>Notes</h2><p>First</p><p>Second</p></section>');

What node printed (React 19.2.7, development build), when the build ran it

node children.jsx

<p>one child</p>
<p>2 children</p>
<section class="card"><h2>Notes</h2><p>First</p><p>Second</p></section>
ok   one child is not wrapped in an array
ok   two children are an array
ok   the wrapper puts the children inside itself

Spreading

When the props already sit in an object, {...object} passes every key of it. Order matters in the same way it does for object spread: a later attribute replaces an earlier one.

Spreading props

ran at build time · server (no DOM) · React dev

spread.jsx

import { renderToStaticMarkup } from 'react-dom/server';

function Link({ href, label, rel = 'none' }) {
  return <a href={href} rel={rel}>{label}</a>;
}

const base = { href: '/docs', label: 'Docs' };

console.log(renderToStaticMarkup(<Link {...base} />));
console.log(renderToStaticMarkup(<Link {...base} label="Guide" />));
console.log(renderToStaticMarkup(<Link label="Guide" {...base} />));

check('spread passes every key', renderToStaticMarkup(<Link {...base} />), '<a href="/docs" rel="none">Docs</a>');
check('a later attribute wins', renderToStaticMarkup(<Link {...base} label="Guide" />), '<a href="/docs" rel="none">Guide</a>');
check('a later spread wins', renderToStaticMarkup(<Link label="Guide" {...base} />), '<a href="/docs" rel="none">Docs</a>');

What node printed (React 19.2.7, development build), when the build ran it

node spread.jsx

<a href="/docs" rel="none">Docs</a>
<a href="/docs" rel="none">Guide</a>
<a href="/docs" rel="none">Docs</a>
ok   spread passes every key
ok   a later attribute wins
ok   a later spread wins

Spreading is handy for passing everything through to an HTML element, and a trap when it passes more than the child needs. Be able to say which props a component takes by reading its signature.

Props in a live DOM

Here is a Card rendered into the framed region with createRoot, which is the call that attaches React to a real DOM node. act makes React finish its work before the next line runs; lesson 8 explains it. This panel runs in happy-dom when the page is built, and the Run button runs it in your browser: the build checks that both print the same text.

A card with children

ran at build time · in happy-dom 20.11.6 · React dev · runs here too

card.jsx

import { act } from 'react';
import { createRoot } from 'react-dom/client';

function Card({ title, children }) {
  return (
    <article>
      <h3>{title}</h3>
      {children}
    </article>
  );
}

const root = createRoot(stage);
await act(async () => root.render(
  <Card title="Today">
    <p>Two lessons.</p>
    <p>One panel.</p>
  </Card>
));
console.log(stage.innerHTML);

check('the children are inside the card',
  stage.innerHTML, '<article><h3>Today</h3><p>Two lessons.</p><p>One panel.</p></article>');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node card.jsx

<article><h3>Today</h3><p>Two lessons.</p><p>One panel.</p></article>
ok   the children are inside the card

Props are read-only

A component must not change its props. Passing a prop down is a one-way street: the parent owns it. The development build enforces that by freezing the object, and the module is in strict mode, so an assignment is an error you can catch and look at.

Trying to change a prop

ran at build time · server (no DOM) · React dev

frozen.jsx

function Price({ amount }) {
  return <p>{amount}</p>;
}

const el = <Price amount={5} />;
let caught = null;

try {
  el.props.amount = 6;
} catch (e) {
  caught = e;
}

console.log(caught instanceof TypeError);
console.log(el.props.amount);

check('assigning to a prop throws a TypeError', caught instanceof TypeError, true);
check('and the prop is unchanged', el.props.amount, 5);

What node printed (React 19.2.7, development build), when the build ran it

node frozen.jsx

true
5
ok   assigning to a prop throws a TypeError
ok   and the prop is unchanged

To show something different, the parent passes a different value. When a component needs a value that changes over time, that is state, which lesson 9 covers.

  • Props are one object, gathered from the attributes. Destructure it in the signature.
  • A default applies to a missing or undefined prop, not to null.
  • children is whatever is between the tags: one value, or an array.
  • Spread with {...props}; a later attribute wins.
  • Props are read-only. Change what you pass, not what you were given.

6 Conditional rendering

A page changes: a message appears, a menu opens, a form shows its errors. JSX has no if tag. The tree is a JavaScript value, so you choose which value with the JavaScript you already know.

Four ways to choose

All four panels below produce the same HTML for the same condition. Use whichever reads best: a ternary when there are two outcomes, && when there is one or nothing, an early return null when a component has nothing to show, and a variable when the branches are large.

Four ways to choose

ran at build time · server (no DOM) · React dev

choose.jsx

import { renderToStaticMarkup } from 'react-dom/server';

function Ternary({ online }) {
  return <p>{online ? 'Online' : 'Offline'}</p>;
}

function And({ unread }) {
  return <p>Inbox{unread && <b> (new)</b>}</p>;
}

function Early({ user }) {
  if (!user) return null;
  return <p>Hello, {user}</p>;
}

function Variable({ admin }) {
  let badge = <i>member</i>;
  if (admin) badge = <b>admin</b>;
  return <p>{badge}</p>;
}

const html = (el) => renderToStaticMarkup(el);

console.log(html(<Ternary online={true} />));
console.log(html(<And unread={true} />));
console.log(html(<And unread={false} />));
console.log(html(<Early user="Ada" />) + '|' + html(<Early />));
console.log(html(<Variable admin={true} />));

check('a ternary picks one of two', html(<Ternary online={false} />), '<p>Offline</p>');
check('&& shows the element when true', html(<And unread={true} />), '<p>Inbox<b> (new)</b></p>');
check('and shows nothing when false', html(<And unread={false} />), '<p>Inbox</p>');
check('an early return renders nothing', html(<Early />), '');
check('and the full branch otherwise', html(<Early user="Ada" />), '<p>Hello, Ada</p>');
check('an element in a variable', html(<Variable admin={false} />), '<p><i>member</i></p>');

What node printed (React 19.2.7, development build), when the build ran it

node choose.jsx

<p>Online</p>
<p>Inbox<b> (new)</b></p>
<p>Inbox</p>
<p>Hello, Ada</p>|
<p><b>admin</b></p>
ok   a ternary picks one of two
ok   && shows the element when true
ok   and shows nothing when false
ok   an early return renders nothing
ok   and the full branch otherwise
ok   an element in a variable

What renders as nothing

false, null, undefined and true are valid children and draw nothing. That is why cond && <X /> works: when cond is false the expression is false, which is nothing. The trouble is the values that are falsy but not in that list.

The 0 that shows up

ran at build time · server (no DOM) · React dev

falsy.jsx

import { renderToStaticMarkup } from 'react-dom/server';

const html = (el) => renderToStaticMarkup(el);
const items = [];

console.log(html(<p>{false}{null}{undefined}{true}</p>));
console.log(html(<p>{items.length && <span>has items</span>}</p>));
console.log(html(<p>{items.length > 0 && <span>has items</span>}</p>));
console.log(html(<p>{'' && <span>x</span>}</p>));

check('false, null, undefined and true draw nothing', html(<p>{false}{null}{undefined}{true}</p>), '<p></p>');
check('0 && ... draws the 0', html(<p>{items.length && <span>has items</span>}</p>), '<p>0</p>');
check('a comparison draws nothing', html(<p>{items.length > 0 && <span>has items</span>}</p>), '<p></p>');
check('an empty string draws nothing', html(<p>{'' && <span>x</span>}</p>), '<p></p>');

What node printed (React 19.2.7, development build), when the build ran it

node falsy.jsx

<p></p>
<p>0</p>
<p></p>
<p></p>
ok   false, null, undefined and true draw nothing
ok   0 && ... draws the 0
ok   a comparison draws nothing
ok   an empty string draws nothing

Make the left side a boolean. {items.length && ...} prints 0 on an empty list, which is a bug that looks like a stray digit. Write items.length > 0 && ..., or use a ternary that returns null.

Changing it in a live DOM

A condition usually comes from state, which lesson 9 covers. Here the state is one line, so you can watch the tree change. The panel clicks the button inside act and prints the framed region before and after. It runs in your browser too, and the build checks that the browser prints the same text.

A toggle

ran at build time · in happy-dom 20.11.6 · React dev · runs here too

reveal.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Reveal() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>{open ? 'Hide' : 'Show'}</button>
      {open && <p>Secret</p>}
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Reveal />));
const before = stage.innerHTML;
console.log(before);

await act(async () => stage.querySelector('button').click());
const after = stage.innerHTML;
console.log(after);

check('closed at first', before, '<div><button>Show</button></div>');
check('open after one click', after, '<div><button>Hide</button><p>Secret</p></div>');

await act(async () => stage.querySelector('button').click());
check('closed again after a second click', stage.innerHTML, before);
check('the paragraph is absent, not hidden', stage.querySelector('p'), null);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node reveal.jsx

<div><button>Show</button></div>
<div><button>Hide</button><p>Secret</p></div>
ok   closed at first
ok   open after one click
ok   closed again after a second click
ok   the paragraph is absent, not hidden

The paragraph is not hidden; it is not there. When open is false the tree has no <p> and React removes the node. That differs from hiding with CSS, where the element stays in the page.

  • Choose with a ternary, &&, an early return null or a variable.
  • false, null, undefined and true render nothing.
  • 0 && ... renders 0. Compare, so the left side is a boolean.
  • A false condition removes the node; it does not hide it.

7 Lists and keys

Most screens are lists: messages, products, lessons. In React a list is an array of elements, and you make the array with map. There is one thing to add to make it work well, and React warns you loudly if you forget it.

Rendering an array

An array of elements is a valid child, so mapping data to elements is the whole technique. map is covered in the Node.js course, and the arrays it works on in its lesson on arrays. Each element gets a key: a string or number that is unique among its siblings and stays with the same piece of data.

A list with keys

ran at build time · server (no DOM) · React dev

keyed.jsx

import { renderToStaticMarkup } from 'react-dom/server';

const books = [
  { id: 1, title: 'Dune' },
  { id: 2, title: 'Emma' },
  { id: 3, title: 'Ulysses' },
];

function BookList({ items }) {
  return (
    <ul>
      {items.map((book) => (
        <li key={book.id}>{book.title}</li>
      ))}
    </ul>
  );
}

const html = renderToStaticMarkup(<BookList items={books} />);
console.log(html);
const withKey = <li key={2}>x</li>;
const withoutKey = <li>x</li>;
console.log(withKey.key, withoutKey.key);

check('every item rendered', html, '<ul><li>Dune</li><li>Emma</li><li>Ulysses</li></ul>');
check('the key is not in the HTML', html.includes('key'), false);
check('the key is kept on the element, as a string', withKey.key, '2');
check('and is null when there is none', withoutKey.key, null);

What node printed (React 19.2.7, development build), when the build ran it

node keyed.jsx

<ul><li>Dune</li><li>Emma</li><li>Ulysses</li></ul>
2 null
ok   every item rendered
ok   the key is not in the HTML
ok   the key is kept on the element, as a string
ok   and is null when there is none

The key does not appear in the HTML and it is not a prop your component can read. It is for React alone. When the array changes, React uses the keys to match old items to new ones, so an item that moved keeps its state and one that was removed is cleaned up. A key from the data, such as an id, does that job. An index in the array does not once items can be reordered or removed, which lesson 22 counts.

Filtering and sorting without changing the data

To show some of the items, or the same ones in another order, derive a new array during the render. filter and map return new arrays. sort does not: it rearranges the array it is called on, which would change the data every other part of the program sees. Sort a copy.

Derive a new array; do not change the old one

ran at build time · server (no DOM) · React dev

derive.jsx

import { renderToStaticMarkup } from 'react-dom/server';

const books = [
  { id: 1, title: 'Ulysses', pages: 730 },
  { id: 2, title: 'Emma', pages: 474 },
  { id: 3, title: 'Dune', pages: 612 },
];

function Titles({ items, minPages }) {
  const long = items.filter((b) => b.pages >= minPages);
  const sorted = long.slice().sort((a, b) => a.title.localeCompare(b.title));
  return (
    <ol>
      {sorted.map((b) => (
        <li key={b.id}>{b.title}</li>
      ))}
    </ol>
  );
}

const html = renderToStaticMarkup(<Titles items={books} minPages={500} />);
console.log(html);
console.log(books.map((b) => b.title).join(','));

check('long books, sorted by title', html, '<ol><li>Dune</li><li>Ulysses</li></ol>');
check('the data was left as it was', books.map((b) => b.title), ['Ulysses', 'Emma', 'Dune']);

What node printed (React 19.2.7, development build), when the build ran it

node derive.jsx

<ol><li>Dune</li><li>Ulysses</li></ol>
Ulysses,Emma,Dune
ok   long books, sorted by title
ok   the data was left as it was

The warning when there is no key

Leave the key off and the list still renders, and React tells you in the console that something is missing. The development build prints the warning once, naming the component that rendered the list. The panel declares that it will warn, and the build checks that the warning appeared and that nothing else did.

A list with no keys

warns, on purpose · in happy-dom 20.11.6 · React dev

unkeyed.jsx

import { act } from 'react';
import { createRoot } from 'react-dom/client';

function Names({ items }) {
  return (
    <ul>
      {items.map((name) => (
        <li>{name}</li>
      ))}
    </ul>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Names items={['Ada', 'Grace', 'Alan']} />));
console.log(stage.innerHTML);

check('how many items rendered', stage.querySelectorAll('li').length, 3);
check('the list still shows every name', stage.innerHTML, '<ul><li>Ada</li><li>Grace</li><li>Alan</li></ul>');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node unkeyed.jsx

Each child in a list should have a unique "key" prop.

Check the render method of `Names`. See https://react.dev/link/warning-keys for more information.
<ul><li>Ada</li><li>Grace</li><li>Alan</li></ul>
ok   how many items rendered
ok   the list still shows every name

Read it as you read the errors in lesson 3: a sentence, then the component stack. The sentence says what is missing; the stack says which component made the list, here Names. The page still shows all three names. A missing key is not a crash, it is a promise React cannot keep: with no stable identity it cannot tell a reordered list from a list of changed items.

  • Render a list by mapping an array to elements.
  • Give each element a key: unique among siblings, from the data, not from the position.
  • The key never reaches the HTML and is not a prop.
  • filter and map make new arrays; sort a copy, never the original.
  • No key still renders, and warns in development.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.