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.
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).
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.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><b>not bold</b></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><b>not bold</b></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 <script>. 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.
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.
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
jsxcalls; the development build adds the file and line. { }holds an expression. Attributes are props:className,htmlFor, andstyleas an object.- Text in a hole is escaped. A fragment groups siblings without a wrapper.
- A tag evaluates to a plain object with a
typeandprops.
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.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.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.
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.
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.
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
undefinedprop, not tonull. childrenis 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.
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.
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.
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 earlyreturn nullor a variable. false,null,undefinedandtruerender nothing.0 && ...renders0. 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.
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.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.
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.
filterandmapmake new arrays;sorta copy, never the original.- No key still renders, and warns in development.