JSX under the Hood
What you'll learn
- Exactly what the JSX transform emits, attributes, expression children, spread props
- Why only expressions fit inside
{}, and the infamous&&-renders-0bug - The naming rules:
className,htmlFor, camelCase styles, numbers becoming pixels - Why fragments exist (the "one parent" rule is really the "one return value" rule)
- Comments, keys, and the syntax bugs everyone hits exactly once
In the previous chapter you learned that JSX compiles into jsx() calls that return plain element objects. This chapter is the practical owner's manual for that syntax: what you may and may not write, why the weird rules exist, and how to read the errors when you break them. Every rule below follows from one fact, JSX is not HTML. It's a function call in disguise.
The transform: before and after
Take an element with attributes and expression children:
const ui = (
<button className="primary" disabled={loading} onClick={save}>
Save {count} items
</button>
);
The compiler turns it into ordinary JavaScript:
Pseudocode model, not real source:
const ui = jsx('button', {className: 'primary',disabled: loading, // expression: evaluated, then passedonClick: save,children: ['Save ', count, ' items'],});
What happens:
- The tag name becomes the first argument: the string
'button'(a component name would become the function itself). - Each attribute becomes a key in the props object. Strings stay strings;
{...}contents are evaluated JavaScript. - Text and
{expression}children are evaluated and collected intochildren, an array when there's more than one. - No DOM is created. This is a plain function call returning a plain object.
Spread props: later wins
Spread attributes obey plain object-spread rules, which makes the order meaningful:
const extra = { id: 'save-btn', className: 'big' };
const ui = <button className="small" {...extra} disabled />;
Pseudocode model, not real source:
const ui = jsx('button', { className: 'small', ...extra, disabled: true });// resulting props: { className: 'big', id: 'save-btn', disabled: true }
What happens: disabled with no value means disabled={true}. And extra's className: 'big' overwrites the earlier 'small', exactly as object spread would. Put {...extra} first and className="small" after, and small wins instead. No special JSX semantics, just JavaScript objects.
Inside {}: expressions only, never statements
The braces are a one-way door back into JavaScript, but only for expressions (things that produce a value), never statements (things that do something without producing one).
Jargon: "expression". Code that evaluates to a value:
a + b,user.name,items.map(...),isOk ? 'yes' : 'no'. The test: can you put it on the right side of=? Jargon: "statement". Code that performs an action:if,for,while, declarations. It produces no value, so there is nothing to pass as a child.
That's why the ternary is the workhorse of conditional JSX, and if is a compile error:
export default function Status({ online }) {
return (
<p>
{online ? 'Online' : 'Offline'}
{/* if (online) { 'Online' } <- a statement: will not compile */}
</p>
);
}
The && pitfall: when 0 shows up on screen
A popular shortcut for "render only if" is &&:
export default function UnreadBadge({ count }) {
return (
<div>
{count && <span>You have {count} unread</span>}
</div>
);
}
What happens:
countis5:5 && <span>…</span>evaluates to the element, rendered correctly.countis0:0 && …short-circuits to0, and React renders numbers, so a literal 0 appears on the page.
The bug is pure JavaScript: && returns the left side when it's falsy, and React ignores false, null, and undefined, but not 0 (or NaN). The fix is to make the left side a real boolean:
{count > 0 && <span>You have {count} unread</span>} // safe
{count ? <span>You have {count} unread</span> : null} // also safe
What React does with each kind of child:
| You pass | React renders |
|---|---|
| string, number | text on screen |
| element | real UI |
| array of the above | each item, in order |
false, true, null, undefined | nothing at all |
| plain object | an error, objects aren't valid children |
Naming: className, htmlFor, and camelCase
<label className="field-label" htmlFor="email">Email</label>
Why not class and for? Because JSX props become DOM properties, not HTML attributes, and the DOM properties are literally named className and htmlFor (element.className = 'x' is how you do it in vanilla JS). It also doesn't hurt that class and for are JavaScript keywords. Same story for tabIndex, readOnly, onClick: these are the platform's own property and event names, spelled in camelCase because that's how the DOM spells them.
style is an object, not a string
export default function Banner() {
return (
<div style={{ marginTop: 8, backgroundColor: 'papayawhip', width: '60%' }}>
Hello
</div>
);
}
What happens:
- The outer braces mean "JavaScript expression"; the inner braces are an object literal. You're passing
{ marginTop: 8, backgroundColor: 'papayawhip', width: '60%' }as thestyleprop. - Keys are camelCase versions of CSS properties (
backgroundColor, notbackground-color), again matching the DOM's own style object. - Numbers become pixels for most properties:
marginTop: 8means8px. Strings pass through untouched:width: '60%'. - Genuinely unitless properties,
zIndex,opacity,fontWeight,lineHeight, take the number as-is.
children is just a prop
Everything between the opening and closing tags arrives as props.children:
function Card({ children }) {
return <section className="card">{children}</section>;
}
// <Card>Hello</Card> -> children: 'Hello'
// <Card><Avatar /></Card> -> children: an element
// <Card><b>A</b><i>B</i></Card> -> children: [element, element]
It can be a string, a number, an element, an array, and in advanced patterns you'll meet later, even a function the component calls to build part of its UI (the "render prop" pattern). No magic: it's one prop among many, just spelled between the tags instead of inside them.
Fragments: the "one parent" rule, demystified
This fails to compile:
function Heading() {
return (
<h1>Hi</h1>
<p>Welcome</p>
);
}
Not because of any JSX rule about parents, but because a JavaScript function returns one value. Two elements are two values. You could wrap them in a div, but sometimes you don't want an extra node in the DOM (grid children, table rows). The fragment is a wrapper that exists in the description but renders no DOM node:
export default function Heading() {
return (
<>
<h1>Hi</h1>
<p>Welcome</p>
</>
);
}
What happens: <> is shorthand for <Fragment>. The transform emits one call whose children is the array [<h1>…, <p>…], one value, legally returned. When the renderer reaches the fragment, it unwraps it and places the children directly in the parent. No wrapper element ever exists in the DOM.
Comments in JSX
// Outside the return: plain JavaScript comments, as always.
return (
<div>
{/* Inside JSX: wrap a block comment in braces. */}
<h1>Title</h1>
</div>
);
Between JSX tags you're in markup-land, so a bare // would be parsed as literal text. Inside braces you're back in JavaScript, where {/* ... */} works. That's the whole rule.
Keys: a label for React, not for you
Rendering a list means returning an array of elements, and arrays need identity. When you write:
{items.map(item => <li key={item.id}>{item.name}</li>)}
key is not delivered to your component and never becomes a DOM attribute. React peels it off and uses it to answer, on the next render: "which of these descriptions corresponds to which one from last time?", so it can update, move, or destroy exactly the right DOM nodes. That's all we'll say here; keys get their own deep-dive in Part 2, where you'll see the bugs they prevent.
Common syntax bugs
| Bug | What you see | The fix |
|---|---|---|
if inside JSX | compile error | ternary, or compute above return |
{count && …} with count = 0 | a stray 0 on screen | {count > 0 && …} |
class="x" | warning, attribute ignored | className="x" |
style="color: red" | type error / warning | style={{ color: 'red' }} |
| two sibling roots returned | compile error | wrap in a fragment |
unclosed <br> or <img> | compile error | self-close: <br /> |
for="email" on a label | warning, attribute ignored | htmlFor="email" |
Putting it together
One complete component using conditional rendering, a list, a fragment, and a style object:
import { useState } from 'react';
const initialItems = [
{ id: 1, name: 'Tea', price: 4 },
{ id: 2, name: 'Coffee', price: 5 },
];
export default function Cart() {
const [items, setItems] = useState(initialItems);
const total = items.reduce((sum, i) => sum + i.price, 0);
return (
<>
<h2 style={{ color: 'teal', marginBottom: 8 }}>Your cart</h2>
{items.length === 0 ? (
<p>Your cart is empty.</p>
) : (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} — ${item.price}{' '}
<button onClick={() => setItems(items.filter(i => i.id !== item.id))}>
Remove
</button>
</li>
))}
</ul>
)}
<p style={{ fontWeight: 'bold' }}>Total: ${total}</p>
<button onClick={() => setItems([])}>Clear cart</button>
</>
);
}
What happens:
- The whole return value is one fragment, several siblings legally become one value, with no wrapper node in the DOM.
{items.length === 0 ? … : …}is a ternary, because only expressions fit in braces. Both states are fully described; the state decides which one exists.{items.map(item => <li key={item.id}>…</li>)},mapproduces an array of elements, rendered in order, each carrying akeyfor React's identity tracking.- The
{' '}after the price is an explicit space, JSX trims whitespace at line boundaries, and this keeps the button from touching the price. - The style props are objects:
marginBottom: 8becomes8px,fontWeight: 'bold'passes through as-is. - Every handler just calls
setItemswith an immutable update (filter, or[]). Not a single DOM API appears anywhere.
And one piece of it, through the transform:
Pseudocode model, not real source:
// <h2 style={{ color: 'teal', marginBottom: 8 }}>Your cart</h2>// becomes:jsx('h2', {style: { color: 'teal', marginBottom: 8 },children: 'Your cart',});// which returns roughly:// { type: 'h2', key: null, props: { style: {...}, children: 'Your cart' } }
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: JSX is "HTML in JavaScript." Reality: it's function-call syntax that resembles HTML. Browsers can't run it; it becomes
jsx()calls at build time. - Misconception:
classNameis React being quirky. Reality: JSX props map to DOM properties, and the DOM property is namedclassName. Same story forhtmlFor. - Misconception: A fragment renders an invisible wrapper element. Reality: it renders nothing at all, it's a grouping device that exists only in the description tree.
- Misconception: Numbers in
stylealways mean pixels. Reality: most do, but genuinely unitless properties (zIndex,opacity,lineHeight…) take the bare number. - Misconception:
&&is a safe "render if" shorthand. Reality: only with a boolean left side. A falsy number short-circuits to that number, and React happily renders numbers, hello, stray0. - Misconception:
keyis passed to your component as a prop. Reality: React consumeskeyitself for matching list items across renders; your component never sees it.
Why it works this way
- Expressions-only keeps the mapping honest. If arbitrary JavaScript could appear in braces, JSX couldn't remain a simple, predictable rewrite into function calls.
- DOM property naming keeps one consistent vocabulary. Whether you set a value in vanilla JS or in JSX, you use the same name, one thing to learn, not two.
- The one-return-value rule is JavaScript's, not React's. Fragments exist precisely because the fix must not add DOM nodes.
- Objects for
styleand props make everything diffable. Plain data can be compared key by key; CSS strings would need parsing.
Try it yourself
- Reproduce the stray-zero bug: render
{count && <b>new!</b>}withcountstarting at0. Observe the0on screen. Then fix it withcount > 0 && …and confirm it disappears. - Paste a small component into an online JSX playground (the Babel REPL works) and read the compiled output. Find your attributes, your
children, and how thekeyis handled. - Write a
Boxcomponent that logsprops.children. Render it once with a single text child, once with two element children. Observe: a string in the first case, an array of elements in the second. - Test spread order: render the same component twice, once
<X a={1} {...rest} />, once<X {...rest} a={1} />whererest = { a: 99 }, and log the prop insideX. Confirm "later wins."
Recap
- JSX compiles to
jsx(type, props)calls; attributes become props,{expressions}are evaluated in place, and spreads obey object-spread order. - Braces accept expressions only, ternaries yes,
ifno. Guard withcond && …only whencondis a true boolean, or a stray0will render. - Props follow DOM property names (
className,htmlFor);styleis an object with camelCase keys, and numbers becomepxexcept on unitless properties. childrenis an ordinary prop; fragments exist because functions return one value, not because of any HTML rule.keyis React's identity label on list elements, never delivered to your component.