Skip to content
Chapter 17Lesson 1

JSX is property syntax for HTML

JSX, the element syntax you write React components in, and where it diverges from the HTML it resembles.

You write this in a React file:

<button className="btn" onClick={handleSave}>Save</button>

Then you inspect the rendered button in the browser’s Elements panel. This is what’s actually in the page:

<button class="btn">Save</button>

className became class. onClick is gone: there’s no onclick attribute, because the handler is bound up near the React root. The text survived; almost nothing else came through verbatim.

That gap is what this lesson is about. JSX looks like HTML, but it’s JavaScript with a small element syntax on top, and it diverges from raw HTML in a handful of specific, learnable places.

You did the conceptual work in the DOM chapter’s “Attributes vs. properties”: the DOM exposes elements as objects whose property names sometimes differ from HTML attributes (className for class, htmlFor for for, since class and for are reserved words in JavaScript). Those property names are what you write in JSX every day.

A piece of JSX is not a string or HTML. It’s a JavaScript expression that evaluates to an object, and a build tool rewrites it into plain function calls before the browser ever sees it. This rewrite is the JSX transform, run in a Next.js app by Turbopack using React’s automatic runtime . You never configure or call it by hand, but every bug in this lesson comes from this step.

Here’s the rewrite. This JSX:

<div className="row">Hello</div>

becomes this function call:

jsx('div', { className: 'row', children: 'Hello' });

That jsx(...) call doesn’t touch the DOM. It returns a plain JavaScript object, an element descriptor , that looks roughly like { type: 'div', props: { className: 'row', children: 'Hello' } }. It’s a recipe, not the meal: only when React renders does it walk the tree of these descriptors and build the real DOM nodes the browser paints.

STEP 1 You write JSX
STEP 2 Transform hidden
STEP 3 Descriptor hidden
STEP 4 Rendered DOM
JSX · Counter.tsx
<button className = "btn" onClick = { handleSave }> Save </button>
What you author: JSX in a .tsx file, with className and the onClick handler written the React way.
STEP 1 You write JSX
STEP 2 Transform hidden
STEP 3 Descriptor hidden
STEP 4 Rendered DOM
JavaScript · emitted by Turbopack
jsx( 'button' , { className : 'btn' , onClick : handleSave , children : 'Save' })
The transform rewrites your JSX into a plain jsx() call. Turbopack does this at build time, so you never write or see it.
STEP 1 You write JSX
STEP 2 Transform hidden
STEP 3 Descriptor hidden
STEP 4 Rendered DOM
JavaScript · return value
{ type : 'button' , props : { className , onClick , children } }
That call returns a plain JavaScript object describing the element, not a DOM node yet.
STEP 1 You write JSX
STEP 2 Transform hidden
STEP 3 Descriptor hidden
STEP 4 Rendered DOM
HTML · DevTools Elements panel
<button class = "btn" > Save </button>
React renders the descriptor into real DOM, the only thing the browser ever sees. className became class, and the handler is bound at the React root, not as an attribute on the button.

The transform and the descriptor in the middle are invisible, which is why the differences between what you wrote and what the browser got are easy to miss. Each section ahead names one.

Lowercase is a tag, uppercase is a component

Section titled “Lowercase is a tag, uppercase is a component”

The transform decides what to pass as the first argument to jsx by reading one thing: the capitalization of the element name. This is mechanical, not a style choice.

A lowercase name compiles to a string:

<button>Save</button>
// → jsx('button', { children: 'Save' })

A string means a built-in HTML element, what React calls an intrinsic element : React turns 'button' into a real <button> DOM node.

An uppercase name compiles to a reference to a value:

<SignInForm />
// → jsx(SignInForm, {})

No quotes, because SignInForm is a variable in scope, your own component function, not a tag name.

This is where the bug happens. Lowercase a component name and the transform reads it as a tag: <signInForm /> asks the browser for a nonexistent <signinform> element, so you get nothing on the page and no error pointing at the cause. Component names are capitalized because the capitalization is the instruction.

Props are the DOM property names you already know

Section titled “Props are the DOM property names you already know”

A prop is a named input you set on an element in JSX, the name="value" pair after the tag. And JSX prop names are DOM property names: when you write className in JSX, you’re using the same identifier you’d use to read or set that property on a DOM node, element.className. JSX just lets you set it declaratively in markup.

You already learned the rename table; JSX is where you use it, regrouped around what changes.

Renamed because the HTML attribute name is a reserved word or not valid camelCase. JSX follows the DOM, which exposes these under a different name:

You write in JSXThe HTML attribute it maps to
classNameclass
htmlForfor
tabIndextabindex
readOnlyreadonly
maxLengthmaxlength
colSpancolspan
rowSpanrowspan

class and for are renamed outright because they’re reserved words; the rest are camelCased to match their DOM property.

Passed straight through, unchanged. data-* and aria-* attributes are written exactly as in HTML, kebab-case and all, and arrive in the DOM untouched:

<div data-row-id="42" aria-live="polite" />

They have no single camelCase DOM property to mirror. A data-* attribute is read back through the dataset object, not a property named dataRowId, so there’s nothing for JSX to camelCase toward.

Event handlers are camelCased, and the value is a function. This is the biggest departure from HTML, where a handler is a string of code: <button onclick="doThing()">. In JSX, the prop is camelCase (onClick, onChange, onSubmit, onKeyDown, onFocus, onBlur) and its value is an actual function, passed by reference:

<button onClick={handleSave}>Save</button>

You hand React the function handleSave; React holds onto it and invokes it later, on click. That word is the heart of a bug you’ll likely hit within your first week.

<button onClick={handleSave}>Save</button>
<button onClick={handleSave()}>Save</button>

The second line adds only the parentheses, but it changes the meaning completely. onClick={handleSave()} calls handleSave immediately, on every render, and passes whatever it returns as the handler. If handleSave saves data, you’ve fired a save on render instead of on click. If it returns nothing, you’ve set onClick={undefined} and the button does nothing. Either way the symptom, a side effect at the wrong time or a dead button, rarely points back at the parentheses.

To pass arguments, wrap the call in an arrow function:

<button onClick={() => removeRow(row.id)}>Delete</button>

Now onClick holds a function again, one that calls removeRow(row.id) when React invokes it on click.

A realistic block ties the rename rules together: a labeled email field with a save button.

<form>
<label htmlFor="email" className="field-label">
Email
</label>
<input id="email" data-analytics="email-field" tabIndex={0} />
<button onClick={handleSave}>Save</button>
</form>

htmlFor, not for, which is reserved in JavaScript. It ties this label to the input with the matching id.

<form>
<label htmlFor="email" className="field-label">
Email
</label>
<input id="email" data-analytics="email-field" tabIndex={0} />
<button onClick={handleSave}>Save</button>
</form>

className, not class, for the same reason. The single most common JSX typo.

<form>
<label htmlFor="email" className="field-label">
Email
</label>
<input id="email" data-analytics="email-field" tabIndex={0} />
<button onClick={handleSave}>Save</button>
</form>

tabIndex, camelCased to match element.tabIndex; the HTML attribute is lowercase tabindex. The value is {0}, a number, not the string "0".

<form>
<label htmlFor="email" className="field-label">
Email
</label>
<input id="email" data-analytics="email-field" tabIndex={0} />
<button onClick={handleSave}>Save</button>
</form>

data-* attributes pass straight through, kebab-case intact: written and rendered identically.

<form>
<label htmlFor="email" className="field-label">
Email
</label>
<input id="email" data-analytics="email-field" tabIndex={0} />
<button onClick={handleSave}>Save</button>
</form>

onClick, camelCase, and the value is the function handleSave by reference. Not a string like HTML’s onclick="…", and not a call.

1 / 1

TypeScript catches these typos: built-in elements are typed, so class or classname on a <div> is a red squiggle before the code ever runs.

One last nuance. A number in a prop value and a number as a child look the same but do different things. <div data-count={5} /> produces the attribute data-count="5", because HTML attributes are always strings, so React stringifies the 5. But <div>{5}</div> produces a text node containing 5. The same {5} in two positions gives two outcomes.

Now check that you can tell renamed props from unchanged ones on sight.

Click every prop whose JSX name differs from its HTML attribute name, then press Check.

<label htmlFor="search" className="lbl" id="search-label">
<input type="search" tabIndex={0} data-testid="search-box" />
</label>
Show the answer

The three renamed props are htmlFor (the attribute is for, a reserved word), className (the attribute is class, also reserved), and tabIndex (camelCased to match the DOM property element.tabIndex; the attribute is lowercase tabindex).

The other three are decoys, written identically in HTML and JSX. id and type are already valid lowercase identifiers with no DOM-property rename, and data-* attributes (data-testid) pass straight through, kebab-case intact, because they have no camelCase property to mirror.

So far the prop values have been static. The moment you need a computed value, a URL from a variable, a disabled state from a boolean, a name from an object, you reach for curly braces. {} opens an expression slot: whatever you put inside is evaluated, and the result is dropped into that spot.

A {} slot goes in exactly two places. As a prop value:

<a href={profileUrl}>Profile</a>
<button disabled={isLoading}>Save</button>

And as a child, between the tags:

<h1>Welcome, {user.name}</h1>
<p>Total: {formatCurrency(amount)}</p>

A {} slot accepts an expression , never a statement . An if block or a for loop won’t go inside {}, because they don’t evaluate to anything you could drop into the markup.

So compute complex logic above the return, in statement-land, and reference the result inside the slot:

const greeting = user ? `Welcome back, ${user.name}` : 'Welcome';
return <h1>{greeting}</h1>;

For small logic, use an inline expression instead, a ternary or &&. Either way: statements go above, expressions go in the braces.

What can a slot render?

Value in the slotWhat appears on the page
string‘hi’the text
number42, 0the text — including 0
nullnothing
undefinednothing
falsenothing
truenothing
arrayeach element rendered in turn
null, undefined, and the booleans render nothing — but 0 is a number, so it shows.

Two rows carry weight. null, undefined, false, and true all render nothing, no error, no blank space, just absence, and that absence is what makes conditional rendering work. But 0 is a number, and numbers render as text, so 0 shows up on the page. The gap between “false renders nothing” and “0 renders” is the most famous footgun in React, and you’ll meet it shortly.

The “renders nothing” rule also gives you a safety habit. Reaching into an object that turned out to be undefined throws an error: user.name crashes the render when user is undefined. Guard it with optional chaining, and the slot renders nothing instead:

<h1>{user?.name}</h1>

When user is absent, user?.name is undefined, which renders nothing.

A {} slot can hold an array of descriptors, which React renders in order. Pair that with .map and you have the standard way to turn data into markup:

<ul>
{rows.map((row) => (
<li key={row.id}>{row.label}</li>
))}
</ul>

rows.map(...) produces an array of <li> descriptors and the slot renders them in order. The key is the part that takes care: a wrong one causes real bugs.

Every item in a mapped list needs a key that satisfies three constraints. It must be stable, the same item getting the same key on every render. It must be unique among its siblings. And it must be tied to the data, not to anything about the rendering, which almost always means the item’s own ID, key={row.id}.

React needs this because when the list re-renders, it has to match each new item to its old counterpart, so it can update the DOM surgically instead of rebuilding it. The key is the identity tag it matches on: the same key means “this is the same item, possibly changed.” Without one, React matches by position, first to first, second to second, which breaks the instant the list reorders. The full machinery is reconciliation , which you’ll meet properly in React’s render model.

key is not a normal prop. The transform pulls it out separately, so the real call is jsx(type, props, key), with key as its own third argument. A component never receives key in its props and can’t read it back, so treat it as a private instruction to React’s reconciler, not as data on the element. That’s why the descriptor in the earlier diagram had no key inside props: it lives outside.

The tempting shortcut is the array index, since .map hands it to you for free:

{rows.map((row, index) => (
<li key={index}>{row.label}</li>
))}

This works until the list is filtered, sorted, reordered, or has an item added or removed anywhere but the end. Delete the first row and every item shifts up a position, but React sees key 0 again and reuses that DOM node, state and all, feeding it the new data. A focused input, a half-typed value, or a checked checkbox stays pinned to position 0 while the data moved out from under it. That is why the key must track the data’s identity, never its position.

{rows.map((row, index) => (
<li key={index}>
<input defaultValue={row.label} />
</li>
))}

The bug. Delete the top row and every item shifts up an index. React sees the same keys (0, 1, …) and reuses the same DOM nodes, so whatever the user typed stays pinned to its position, not its row. The data moved; the input state didn’t.

What if your data has no natural ID, such as a list you built in memory? Generate a stable ID once, when the item is created, then reuse it; crypto.randomUUID() gives you one. Never generate the ID during render: key={Math.random()} produces a fresh key every render, so React thinks every item is new and throws away all the DOM nodes each time. The key has to be as stable as the item it identifies.

The starter below maps over a list with the index as the key. Fix it.

This task list uses the array index as its key. Each row has an uncontrolled input pre-filled with its label. Give each item a stable key tied to the data instead — then the test that deletes the top row will pass, because the input state follows the right row instead of sticking to its position.

Preview
    Show the answer

    Swap the index for the task’s own id:

    {tasks.map((task) => (
    <li key={task.id}>
    <input defaultValue={task.label} className="border px-2 py-1" />
    </li>
    ))}

    The index parameter is no longer needed. The key now travels with the row, so when the top task is removed React matches each surviving row to its previous DOM node by identity, and the input state follows the correct row instead of staying pinned to position 0.

    To render something only sometimes, JSX gives you two idioms.

    For a one-branch decision (render this, or render nothing) use &&:

    {isAdmin && <AdminPanel />}

    When isAdmin is truthy, && evaluates to its right side and <AdminPanel /> renders. When isAdmin is falsy, && short-circuits to false, which renders nothing.

    For a two-branch decision (render this or that) use a ternary:

    {user ? <Dashboard /> : <SignInPrompt />}

    If user exists you get the dashboard, otherwise the sign-in prompt.

    Now the trap. Say you want to render a list only when it has items:

    {items.length && <List items={items} />}

    When items has entries, items.length is a positive number, so the list renders. But when items is empty, items.length is 0 — falsy, so && short-circuits and evaluates to 0, the number itself, not false. React renders numbers as text, so a stray 0 appears where you expected nothing. Before you read the fix, predict the output:

    The cart is empty. What text appears on the page when this component renders? Predict what this program prints, then press Check.

    function CartBadge({ items }) {
    return <div>{items.length && <p>You have items</p>}</div>;
    }
    // Rendered with an empty cart:
    <CartBadge items={[]} />;

    The fix is to make the left side of && a real boolean. Compare it explicitly:

    {items.length > 0 && <List items={items} />}

    Now the left side is true or false, never 0, and the empty case renders nothing. Boolean(items.length) works too. The project’s rule: reach for condition && <Node /> only when condition is a boolean; when the left side is a number, coerce it first.

    Fragments group siblings without a wrapper

    Section titled “Fragments group siblings without a wrapper”

    A JSX expression must evaluate to a single root descriptor. Return two siblings side by side and you get a syntax error, because two adjacent expressions aren’t one value:

    return (
    <h1>Title</h1>
    <p>Body</p>
    );

    Wrapping them in a <div> compiles but adds a real DOM node that exists only to satisfy the one-root rule. The fix is a fragment, <>...</>, which groups siblings into one root without emitting any node:

    return (
    <div>
    <h1>Title</h1>
    <p>Body</p>
    </div>
    );

    The <div> is a real DOM node added only to satisfy the one-root rule. It can sever a flex or grid parent from its children, and it inserts an invalid node into structures like <ul>/<li>.

    The shorthand <>...</> can’t carry a key. When a .map returns a fragment of siblings, use the long form <React.Fragment key={...}> instead.

    Some HTML elements never have children: an <img> holds nothing between an opening and closing tag. HTML calls these void elements, and JSX requires them to self-close with the trailing slash:

    <img src="/logo.svg" alt="Logo" />
    <input type="email" />
    <br />
    <hr />

    Drop the slash and <img src="/logo.svg"> sends the parser looking for a closing </img> that never comes, and the expression fails to compile.

    The four you’ll meet daily are <img>, <input>, <br>, and <hr>. The full set, for recognition only, is <img>, <input>, <br>, <hr>, <meta>, <link>, <source>, <col>, <area>, <base>, <embed>, <track>, and <wbr>. Non-void elements may self-close when they have no children, so <div /> is legal, but the common case is a childless component, <SignInForm />.

    The edges: comments, types, children, escaping, and style

    Section titled “The edges: comments, types, children, escaping, and style”

    Inside a JSX tree, a comment is its own expression slot: {/* ... */}.

    return (
    <ul>
    {/* one row per active subscription */}
    {subscriptions.map((sub) => (
    <li key={sub.id}>{sub.plan}</li>
    ))}
    </ul>
    );

    A bare // in the tree comments out nothing: the parser reads it as a string child and renders it as literal text. Inside the markup, always use the brace form.

    Built-in HTML elements are typed through a registry called JSX.IntrinsicElements , so <button> knows exactly which props it takes and their types. Your own components are typed by their declared prop type. Write classname instead of className and TypeScript flags it before the code runs. That same type information powers prop autocomplete in your editor.

    Hover a prop and TypeScript tells you what it expects:

    <button className="btn" onClick={handleSave}>
    Save
    </button>

    Note that onClick accepts a specific kind of function, not “anything”; TypeScript would reject a string there, enforcing the “value is a function” rule from earlier.

    Content nested between a component’s opening and closing tags arrives inside the component as a prop named children. <Card>hello</Card> calls Card with children: 'hello'. Recognize it for now; designing components around it comes in the components chapter.

    JSX escapes by default; dangerouslySetInnerHTML is the opt-out

    Section titled “JSX escapes by default; dangerouslySetInnerHTML is the opt-out”

    Text children are HTML-escaped automatically. If comment holds the string <script>steal()</script>, then <p>{comment}</p> renders it as visible text, the literal characters, not a script tag the browser runs. React treats interpolated content as data, never markup, which closes a whole class of cross-site scripting attacks for free.

    The escape hatch, for the rare time you have a trusted HTML string to inject, is dangerouslySetInnerHTML:

    <article dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />

    The alarming name and awkward {{ __html: ... }} shape are deliberate: React makes the unsafe path look unsafe so you can’t reach for it by accident. Its only legitimate uses are HTML you’ve already sanitized, like Markdown rendered through react-markdown or CMS rich text run through dompurify. Hand it raw user input and you’ve reopened the XSS hole the default escaping closed.

    In HTML, inline styles are a string: style="margin-top: 8px". In JSX, style takes an object with camelCase property names:

    <div style={{ marginTop: 8 }} />

    The outer {} is the expression slot, the inner {} is the object literal. Property names are camelCased (marginTop, not margin-top), and numeric values get px added where it makes sense. You’ll reach for style rarely: Tailwind utility classes are the default way you’ll style everything, and inline style is reserved for genuinely dynamic values that can’t be a utility class, like a transform computed at runtime.

    You write JSX; the browser receives HTML. Four differences between them cause most bugs.

    1. className, not class. htmlFor, not for. Both HTML names are reserved words in JavaScript. TypeScript flags them; learn to spot them anyway.
    2. Key on data identity, never the array index. key={row.id}, not key={index}. The index looks fine until the list reorders, then state lands on the wrong row.
    3. The && 0-trap. {items.length && ...} paints a stray 0 when the array is empty, because 0 is falsy and renders. Coerce to a boolean: items.length > 0 && ....
    4. Pass the handler, don’t call it. onClick={handleSave} hands React the function; onClick={handleSave()} calls it on every render. To pass arguments, wrap it: onClick={() => removeRow(row.id)}.

    You’re reviewing a teammate’s pull request. Each line below is lifted from a different file. Which lines ship a bug? Select all that apply.

    <label for="email">Email</label>
    <button onClick={openModal}>Open settings</button>
    <span>{cart.length && <CartCount />}</span>
    <input type="email" autoComplete="email" />

    The React docs go deeper on the patterns above, and the Babel REPL lets you watch the JSX transform happen live.