React – JSX

June 18, 20264 min readUpdated 8/18/2026

JSX is the HTML-looking syntax inside a React component. It is not HTML, it is not a template language, and knowing what it actually compiles to explains most of its rules.

It is function calls

This:

<Card className="product-card">
  <Card.Title>{product.name}</Card.Title>
</Card>

…compiles to roughly this:

jsx(Card, {
  className: 'product-card',
  children: jsx(Card.Title, { children: product.name }),
});

Every consequence below follows from that. The tags are arguments, the attributes are an object, and the whole expression evaluates to a plain JavaScript value you can store in a variable, put in an array, or return from a function.

That is why you can do this, and why it is not a special feature — it is just a value:

const label = <Badge bg="primary">{selectedToppings.length} selected</Badge>;

One root element

A function returns one value, so JSX returns one element. Wrap siblings in a real element when you want one, or in a fragment when you do not:

return (
  <>
    <h1>Menu</h1>
    <p>Choose a pizza</p>
  </>
);

<>…</> is shorthand for <Fragment>. It produces no DOM node at all, which matters when a wrapper div would break a CSS grid or an invalid-HTML rule (you cannot put a div between <tr> and <td>).

The shorthand cannot take a key. If you are rendering a list of fragments, use the long form:

{groups.map((group) => (
  <Fragment key={group.id}>
    <dt>{group.label}</dt>
    <dd>{group.value}</dd>
  </Fragment>
))}

Attributes are JavaScript properties, so some names change

class and for are reserved words in JavaScript. JSX uses the DOM property names instead:

<div className="product-card">                    {/* not class= */}
<label htmlFor={`${formId}-email`}>Email</label>  {/* not for= */}

Everything else follows the same rule — DOM property names, so camelCase: onClick, tabIndex, readOnly, maxLength, autoComplete.

The exceptions are the attributes that are not DOM properties: data-* and aria-* keep their hyphens exactly as in HTML.

<div className="product-thumb" aria-hidden="true">
  {isPizza ? '🍕' : '🥤'}
</div>

Also: every tag must close. <br> is <br />, <img> is <img />. There is no implicit closing, because there is no HTML parser involved.

Curly braces

Braces escape from markup back into JavaScript. Any expression goes in them:

<Card.Title as="h3" className="h6 fw-bold mb-1">
  {product.name}
</Card.Title>

<span className="fw-bold">
  from <span className="text-pizza-red">{formatMoney(cheapest)}</span>
</span>

An expression, not a statement. {if (x) …} is a syntax error; use a ternary, or move the if above the return. That is Conditional Rendering.

Braces work in attributes too, and that is how you pass anything other than a string:

<ProductCard product={product} onSelect={handleSelect} />
<Button disabled={loading}>Sign in</Button>
<Modal show={product !== null} onHide={onHide} size="lg" centered scrollable>

size="lg" is a string so it needs no braces. centered with no value at all is shorthand for centered={true}.

The double braces

style takes an object, and an object literal inside braces is two sets of braces. Nothing special is happening:

<div style={{ fontSize: '10rem', lineHeight: 1 }} aria-hidden="true">
  🍕
</div>

The outer pair says "JavaScript here", the inner pair is the object. Keys are camelCased and values are strings — fontSize, not font-size. Numbers get px appended where a unit is expected, which is why lineHeight: 1 stays unitless (it is one of the properties React knows not to touch).

Prefer className. Inline styles cannot do hover states or media queries, and they cost a new object on every render.

What renders and what does not

This trips up nearly everyone eventually:

{null}         {/* nothing */}
{undefined}    {/* nothing */}
{false}        {/* nothing */}
{true}         {/* nothing */}

{0}            {/* renders "0" — a literal zero on the page */}
{''}           {/* nothing, but see below */}
{NaN}          {/* renders "NaN" */}

Booleans, null and undefined are skipped — which is what makes conditional rendering work. Numbers are not, and 0 is a number.

So this is a bug waiting for an empty cart:

{/* When itemCount is 0, this renders "0" next to the Cart button. */}
{totals.itemCount && <Badge>{totals.itemCount}</Badge>}

The fix is to compare, so the left side is a boolean rather than a number. The real navbar does exactly this:

{totals.itemCount > 0 && (
  <Badge bg="light" text="dark" pill className="cart-badge">
    {totals.itemCount}
  </Badge>
)}

Whitespace

JSX collapses whitespace and drops it entirely at the start and end of a line, which means a space you meant to keep can vanish. {' '} is the explicit space:

<p className="small text-muted mb-0">
  Demo accounts — <code>customer@pizza.test</code> / <code>pizza123</code> and{' '}
  <code>admin@pizza.test</code> / <code>admin123</code>.
</p>

Without it the line break before <code> would swallow the space and you would read "and admin@pizza.test".

Comments

An HTML comment inside JSX renders as text. Use a JavaScript comment inside braces:

{/* Crust and toppings only make sense for a pizza. */}
{isPizza && (
  <>
    {/* … */}
  </>
)}

Above the return, ordinary // and /* */ comments work normally — it is only inside the markup that braces are needed.

Next

Props — how a component takes arguments.