JSX

Publish at:

Now we know how the renderer works. But when we write React code, we don't use objects, we us JSX. That leaves the next practical question:

If React wants element objects, what exactly is JSX?

We will keep using the same hole-driven method[4]: start with the smallest attempt, inspect the constraint that fails, and add only the next justified idea.

The JavaScript parser #

Suppose we treat JSX as if it were ordinary JavaScript syntax:

const element = <div>Hello</div>;

root.render(element);

Waiting to run

Not run yet.

This fails before React even gets a chance to run.

That is the first useful constraint. The JavaScript engine itself does not understand JSX syntax[1].

So JSX is not:

  • a browser DOM node
  • a React element object
  • ordinary JavaScript syntax

It is source syntax that needs another step before the runtime can execute it.

That already gives us the first precise sentence:

JSX is syntax, not the runtime value itself.

The syntax #

From the previous article, we already know which kind of runtime value React accepts:

const element = React.createElement('div', null, 'Hello');

That means the missing step has to turn JSX into ordinary JavaScript that creates a React element object[2][3].

With a JSX transform enabled, we can run the original idea again:

const element = <div>Hello</div>;

console.log(element); root.render(element);

Waiting to run

Not run yet.

Now it works, and the logged value has the same React element shape we studied earlier.

For the purpose of demos, the JSX transform uses the classic runtime, so this snippet compiles to code close to:

const element = React.createElement('div', null, 'Hello');

Many modern toolchains use the automatic runtime instead and compile to helpers imported from react/jsx-runtime[3]. The helper names differ, but the important mental model does not:

JSX compiles to ordinary JavaScript that creates React element descriptions.

Curly braces #

Once JSX starts looking HTML-like, it is easy to guess wrongly about the meaning of { ... }.

A good way to test that meaning is to put a statement there:

const isReady = true;

root.render(<div>{if (isReady) { 'Ready'; }}</div>);

Waiting to run

Not run yet.

This fails because the braces are not a miniature statement block. They mark a place where JSX expects a JavaScript expression.

So the next valid attempt uses an expression:

const isReady = true;
const label = 'Ready';

root.render( <div>{isReady ? label.toUpperCase() : 'Waiting'}</div> );

Waiting to run

Not run yet.

That gives us a useful rule:

{ ... } in JSX opens a hole for a JavaScript expression.

That is why variables, function calls, ternaries, arithmetic, and object lookups fit there, while statements such as if, for, and const do not.

Tag names and element type #

JSX also preserves the same distinction between host tags and component types that we already saw with createElement(...).

function Message() {
  return <strong>Hello from component</strong>;
}
const hostElement = <div>Hello from host tag</div>;
const componentElement = <Message />;
console.log('host type:', hostElement.type);
console.log('component type:', componentElement.type);

root.render( <section> {hostElement} {componentElement} </section> );

Waiting to run

Not run yet.

The same rule is still in force:

  • lowercase tag names such as <div /> become host element types
  • uppercase tag names such as <Message /> refer to JavaScript bindings used as component types

So JSX does not replace the element model. It is just a more compact way to write the same distinction.

Attributes as props #

The next question is structural:

when we write attributes and nested tags in JSX, where do those pieces go?

The easiest way to answer that is to inspect the resulting element object.

const accent = 'tomato';
const element = (
<button
className="pill"
title={'Open docs'}
style={{
border: '1px solid ' + accent,
padding: '0.45rem 0.6rem',
borderRadius: '0.5rem'
}}
>
<strong>Open</strong> docs
</button>
);

console.log(element.props); root.render(element);

Waiting to run

Not run yet.

That output makes the mapping visible:

  • JSX attributes become entries in props
  • nested JSX becomes props.children
  • expression-valued attributes such as title={...} and style={...} stay ordinary JavaScript values

This is why JSX looks like markup but behaves like JavaScript syntax layered over the React element object.

JSX expression position #

A JSX position still has to produce one expression result. We can locate that boundary by trying two siblings with no wrapper:

const element = (
  <h1>Title</h1>
  <p>Body</p>
);

root.render(element);

Waiting to run

Not run yet.

The failure tells us the same thing in another form: this position expects one JSX expression, not two adjacent ones.

A fragment fixes the hole without adding an extra DOM node[5].

const element = (
  <>
    <h1>Title</h1>
    <p>Body</p>
  </>
);

console.log(element); root.render(element);

Waiting to run

Not run yet.

The logged value is still just another React element object. The difference is that its type is React.Fragment.

So even fragments fit the same model:

JSX always compiles to element descriptions; fragments are just the element form used when we need grouping without an extra host DOM node.

JSX-subset transpiler #

So far we have looked at JSX from the syntax side: what it means, what it compiles to, and how it fits into React's element model.

There is another side to the same problem:

once JSX exists as source text, how does it move toward something renderable?

That question points back toward the previous renderer article. Instead of asking what JSX is, we ask how a small piece of JSX-like input could be turned into something React can actually render.

To make that bridge visible, we can build a tiny transpiler ourselves.

Strictly speaking, the transpiler still should not target the DOM directly. React's input layer is the element tree, not the DOM tree. The DOM only appears one step later, when the renderer reads that element tree.

So the small experiment is:

can we take a JSX-like source string, turn it into React element objects, and then let React render the result?

For a very small subset, yes.

The first version only handles:

  • one root element
  • lowercase host tags
  • quoted string attributes
  • plain text children
  • nested elements

Here is a tiny transpiler for that subset:

function domNodeToReact(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent.trim();
      return text ? text : null;
    }
if (node.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const props = {};
for (const attr of node.attributes) {
props[attr.name] = attr.value;
}
const children = Array.from(node.childNodes)
.map(domNodeToReact)
.filter((child) => child !== null);
return React.createElement(node.tagName.toLowerCase(), props, ...children);
}
function transpileJsxSubset(source) {
const doc = new DOMParser().parseFromString(
source,
'text/html'
);
const rootNode = doc.body.firstElementChild;
return domNodeToReact(rootNode);
}
const source =     &lt;section&gt;       &lt;h1&gt;Hello&lt;/h1&gt;       &lt;p&gt;Built from source text&lt;/p&gt;     &lt;/section&gt;  ;
const element = transpileJsxSubset(source);

console.log(element); root.render(element);

Waiting to run

Not run yet.

This gives us the bridge between the JSX article and the renderer article:

  • the input starts as source text
  • the transpiler parses that source into a tree shape
  • the output is a React element tree built with React.createElement(...)
  • React DOM then renders that tree into real DOM nodes

That is the full path in miniature.

There is still one more useful hole to fill. The previous demo copies HTML attributes directly, but React's props are not exactly the same as raw HTML attributes. className and style are the easiest places to see the mismatch.

So we can add a small translation layer:

function kebabToCamel(name) {
    return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
  }
function parseInlineStyle(styleText) {
const style = {};
for (const part of styleText.split(';')) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const [rawName, ...rawValueParts] = trimmed.split(':');
const rawValue = rawValueParts.join(':').trim();
if (!rawName || !rawValue) {
continue;
}
style[kebabToCamel(rawName.trim())] = rawValue;
}
return style;
}
function mapDomAttributesToReactProps(node) {
const props = {};
for (const attr of node.attributes) {
if (attr.name === 'class') {
props.className = attr.value;
continue;
}
if (attr.name === 'style') {
props.style = parseInlineStyle(attr.value);
continue;
}
props[attr.name] = attr.value;
}
return props;
}
function domNodeToReact(node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
return text ? text : null;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const props = mapDomAttributesToReactProps(node);
const children = Array.from(node.childNodes)
.map(domNodeToReact)
.filter((child) => child !== null);
return React.createElement(node.tagName.toLowerCase(), props, ...children);
}
function transpileJsxSubset(source) {
const doc = new DOMParser().parseFromString(
source,
'text/html'
);
const rootNode = doc.body.firstElementChild;
return domNodeToReact(rootNode);
}
const source =     &lt;button       class="pill"       style="         border: 1px solid tomato;         padding: 0.45rem 0.6rem;         border-radius: 0.5rem;       "       title="Open docs"     &gt;       Open docs     &lt;/button&gt;  ;
const element = transpileJsxSubset(source);

console.log(element.props); root.render(element);

Waiting to run

Not run yet.

Now the shape is much closer to React's real input model. We are still not implementing full JSX, but we are already doing the same kind of work a real transform has to do:

  • parse source syntax into a tree
  • preserve the distinction between tags, attributes, and children
  • translate source-level attribute spelling into the prop shape React expects
  • produce React element objects that the renderer can consume

This also makes the boundary of "real JSX" easier to see. A production JSX transform still has to handle much harder cases:

  • JavaScript expressions in { ... }
  • component references such as <Message />
  • fragments
  • spread props
  • event handlers
  • multiple roots and better error reporting

So this tiny transpiler is not React's JSX transform, but it is close enough to widen the picture:

JSX is source syntax, a transform turns that syntax into React element objects, and React DOM renders those objects into the visible tree.

Final definition #

JSX is a syntax layer for writing React element descriptions more compactly. It is not HTML, and it is not what the browser executes directly.

The runtime story is still the same as before:

  1. JSX compiles to ordinary JavaScript
  2. that JavaScript creates React element objects
  3. the renderer reads those element objects and produces DOM

So JSX does not replace the model from the first two articles. It makes that model easier to write.

Summary #

We started by letting the parser reject raw JSX, which told us JSX is not ordinary JavaScript syntax. Then we added the missing transform and inspected the resulting values.

That process gives us the answer:

  • JSX is a syntax extension, not a runtime value by itself
  • JSX compiles to ordinary JavaScript that creates React element objects
  • { ... } marks expression holes inside JSX
  • lowercase tags become host element types, and uppercase tags refer to component bindings
  • JSX attributes become props, and nested content becomes props.children
  • fragments let JSX produce one grouped element description without adding an extra host node

So the right sentence to keep in your head is:

JSX is syntax for describing the same React element objects we already know.

Notes

  1. The live demos in this article use the React 19.2.5 development bundle. Demos marked as JSX are compiled at run time with Babel Standalone using the classic React runtime so the generated code stays close to `React.createElement(...)`. Many modern toolchains use the automatic runtime instead, which compiles JSX to helpers from `react/jsx-runtime`.

References

  1. React: Writing Markup with JSX (opens in a new tab) · Back
  2. React `createElement` API reference (opens in a new tab) · Back
  3. Babel `@babel/plugin-transform-react-jsx` (opens in a new tab) · Back
  4. GHC Typed Holes (opens in a new tab) · Back
  5. React built-in components (opens in a new tab) · Back