Skip to content

JSX Compilation and React Elements

intermediate11 min read

JSX Looks Like HTML. It Isn't.

Every React developer writes JSX daily. Most treat it as HTML with superpowers. That mental model is wrong, and it causes real bugs — broken event handlers, mysterious rendering failures, and confusion about what JSX can and cannot do.

JSX is a syntax extension for JavaScript. Your browser has never seen JSX. Before your code runs, a compiler (Babel, SWC, or TypeScript) transforms every piece of JSX into a React.createElement() call. The result is a plain JavaScript object — a React element — that describes what should appear on screen.

Mental Model

Think of JSX as syntactic sugar for function calls. Writing <div className="box">Hello</div> is identical to calling React.createElement('div', { className: 'box' }, 'Hello'). The compiler does the translation. The browser only ever sees the function call, never the angle brackets. Every attribute, every child, every nested component — all become arguments to createElement.

The Compilation Step

Here is what the compiler actually produces:

// You write this:
const element = (
  <div className="container">
    <h1>Hello</h1>
    <p>World</p>
  </div>
);

// Compiler produces this (classic runtime):
const element = React.createElement(
  'div',
  { className: 'container' },
  React.createElement('h1', null, 'Hello'),
  React.createElement('p', null, 'World')
);

Since React 17, there is a new JSX transform that does not require React to be in scope:

// New transform (React 17+):
import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';

const element = _jsxs('div', {
  className: 'container',
  children: [
    _jsx('h1', { children: 'Hello' }),
    _jsx('p', { children: 'World' }),
  ],
});

The new transform uses jsx for single-child elements and jsxs for multiple children. The key difference: children are passed as a prop (children), not as separate arguments.

Why the new JSX transform exists

The classic transform required import React from 'react' in every file that used JSX, even though the developer never referenced React directly. The new transform (RFC #107) eliminates this requirement. The compiler auto-imports jsx from react/jsx-runtime. This also enables future optimizations — the runtime can distinguish between static trees (jsxs) and dynamic ones (jsx), which the React Compiler leverages for automatic memoization.

React Elements Are Plain Objects

React.createElement does not create DOM nodes. It returns a plain JavaScript object — a React element — that describes what to render:

// This is what a React element actually looks like:
const element = {
  $$typeof: Symbol.for('react.element'), // Security marker
  type: 'div',
  key: null,
  ref: null,
  props: {
    className: 'container',
    children: [
      { $$typeof: Symbol.for('react.element'), type: 'h1', props: { children: 'Hello' } },
      { $$typeof: Symbol.for('react.element'), type: 'p', props: { children: 'World' } },
    ],
  },
};

Critical observations:

  1. type is a string for HTML elements ('div', 'span'), or a function/class reference for components
  2. props holds all attributes and children
  3. $$typeof is a Symbol that prevents XSS attacks — JSON cannot represent Symbols, so a malicious JSON payload cannot inject fake React elements
  4. These objects are immutable and cheap to create — React creates thousands of them on every render
Common Trap

React elements are immutable. You cannot modify element.props.className after creation. Each render creates new element objects. This is by design — immutability is what makes reconciliation (diffing) reliable. If you find yourself trying to mutate a React element, you are fighting the system.

Why JSX Is Not HTML: The Differences That Bite

Attribute Names

JSX uses camelCase JavaScript property names, not HTML attribute names:

// HTML:       class,    for,        tabindex,     onclick
// JSX:        className, htmlFor,    tabIndex,     onClick

<label htmlFor="email" className="field-label" tabIndex={0}>
  Email
</label>

Expressions, Not Statements

JSX can contain JavaScript expressions inside {}, but not statements:

// This works — expression:
<div>{isAdmin ? 'Admin' : 'User'}</div>
<div>{items.map(item => <li key={item.id}>{item.name}</li>)}</div>
<div>{count > 0 && <Badge count={count} />}</div>

// This breaks — statement:
// <div>{if (isAdmin) { return 'Admin' }}</div>  // SyntaxError
// <div>{for (let i = 0; i < 5; i++) { ... }}</div>  // SyntaxError

Self-Closing Tags Are Required

In HTML, <img> and <input> don't need closing tags. In JSX, every element must be closed:

<img src="photo.jpg" />    // Must self-close
<input type="text" />      // Must self-close
<br />                      // Must self-close
<MyComponent />             // Components too

Style Takes an Object

// HTML: style="color: red; font-size: 16px"
// JSX:
<div style={{ color: 'red', fontSize: '16px' }}>Styled</div>

The outer {} is a JSX expression. The inner {} is a JavaScript object literal. CSS property names are camelCase (fontSize, not font-size).

Production Scenario: Dynamic Element Types

In production, you sometimes need to render different HTML elements based on a prop:

function Heading({ level, children }) {
  // type must be a variable starting with uppercase, or a string
  const Tag = `h${level}`;
  return <Tag>{children}</Tag>;
}

// Usage:
<Heading level={2}>Section Title</Heading>
// Compiles to: createElement('h2', null, 'Section Title')
Common Trap

If you write <tag> with a lowercase first letter, React treats it as a string HTML element, not a component. <myComponent /> renders as an unknown HTML element <mycomponent>. Components must start with an uppercase letter: <MyComponent />. This is a compile-time convention — the JSX compiler uses the case to decide whether type should be a string or a reference.

Execution Trace
Write JSX:
`AppHeader title='Hello' //App`
Developer writes JSX syntax
Compile:
`jsx(App, ( children: jsx(Header, ( title: 'Hello' )) ))`
Babel/SWC transforms to function calls
Execute:
`( type: App, props: ( children: ( type: Header, props: ( title: 'Hello' ) ) ) )`
Function calls return plain objects
Reconcile:
React compares new tree with previous tree
Diff algorithm runs on the object tree
Commit:
DOM mutations applied
Only changed nodes are updated in the real DOM
Common Mistakes
  • Wrong: Treating JSX as HTML — using class instead of className Right: Use camelCase JSX attributes: className, htmlFor, tabIndex, onClick

  • Wrong: Putting statements inside JSX expressions: {if (x) ...} Right: Use ternary expressions, && short-circuit, or extract logic above the return

  • Wrong: Starting component names with lowercase letters Right: Always capitalize component names: <MyComponent />, not <myComponent />

  • Wrong: Assuming JSX creates DOM nodes directly Right: JSX creates React element objects — lightweight descriptions. DOM nodes are created later in the commit phase.

Quiz
What does this JSX compile to?
Quiz
Why does React use Symbol.for('react.element') in the $$typeof field?
Quiz
What happens if you write <mybutton onClick={handler} /> in JSX?
Key Rules
  1. 1JSX compiles to React.createElement (classic) or jsx() (new transform) — it is function calls, not HTML
  2. 2React elements are plain objects with type, props, key, and ref — they describe UI, they are not DOM nodes
  3. 3$$typeof Symbol prevents XSS attacks by making elements impossible to forge from JSON
  4. 4Component names must start with uppercase — lowercase is treated as an HTML element string
  5. 5JSX expressions ({}) accept only expressions, not statements — use ternary and && for conditionals

Challenge: Trace the Compilation

Manual JSX Compilation

1/11