Skip to main content

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-0 bug
  • 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 passed
onClick: save,
children: ['Save ', count, ' items'],
});

What happens:

  1. The tag name becomes the first argument: the string 'button' (a component name would become the function itself).
  2. Each attribute becomes a key in the props object. Strings stay strings; {...} contents are evaluated JavaScript.
  3. Text and {expression} children are evaluated and collected into children, an array when there's more than one.
  4. 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:

  1. count is 5: 5 && <span>…</span> evaluates to the element, rendered correctly.
  2. count is 0: 0 && … short-circuits to 0, 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 passReact renders
string, numbertext on screen
elementreal UI
array of the aboveeach item, in order
false, true, null, undefinednothing at all
plain objectan 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:

  1. The outer braces mean "JavaScript expression"; the inner braces are an object literal. You're passing { marginTop: 8, backgroundColor: 'papayawhip', width: '60%' } as the style prop.
  2. Keys are camelCase versions of CSS properties (backgroundColor, not background-color), again matching the DOM's own style object.
  3. Numbers become pixels for most properties: marginTop: 8 means 8px. Strings pass through untouched: width: '60%'.
  4. 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

BugWhat you seeThe fix
if inside JSXcompile errorternary, or compute above return
{count && …} with count = 0a stray 0 on screen{count > 0 && …}
class="x"warning, attribute ignoredclassName="x"
style="color: red"type error / warningstyle={{ color: 'red' }}
two sibling roots returnedcompile errorwrap in a fragment
unclosed <br> or <img>compile errorself-close: <br />
for="email" on a labelwarning, attribute ignoredhtmlFor="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:

  1. The whole return value is one fragment, several siblings legally become one value, with no wrapper node in the DOM.
  2. {items.length === 0 ? … : …} is a ternary, because only expressions fit in braces. Both states are fully described; the state decides which one exists.
  3. {items.map(item => <li key={item.id}>…</li>)}, map produces an array of elements, rendered in order, each carrying a key for React's identity tracking.
  4. The {' '} after the price is an explicit space, JSX trims whitespace at line boundaries, and this keeps the button from touching the price.
  5. The style props are objects: marginBottom: 8 becomes 8px, fontWeight: 'bold' passes through as-is.
  6. Every handler just calls setItems with 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

Rendered diagram (PNG hi-res):

rendered diagram

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: className is React being quirky. Reality: JSX props map to DOM properties, and the DOM property is named className. Same story for htmlFor.
  • 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 style always 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, stray 0.
  • Misconception: key is passed to your component as a prop. Reality: React consumes key itself 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 style and props make everything diffable. Plain data can be compared key by key; CSS strings would need parsing.

Try it yourself

  1. Reproduce the stray-zero bug: render {count && <b>new!</b>} with count starting at 0. Observe the 0 on screen. Then fix it with count > 0 && … and confirm it disappears.
  2. 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 the key is handled.
  3. Write a Box component that logs props.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.
  4. Test spread order: render the same component twice, once <X a={1} {...rest} />, once <X {...rest} a={1} /> where rest = { a: 99 }, and log the prop inside X. 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, if no. Guard with cond && … only when cond is a true boolean, or a stray 0 will render.
  • Props follow DOM property names (className, htmlFor); style is an object with camelCase keys, and numbers become px except on unitless properties.
  • children is an ordinary prop; fragments exist because functions return one value, not because of any HTML rule.
  • key is React's identity label on list elements, never delivered to your component.

Next

Components and Purity →