JSX Internals and the Compilation Pipeline
JSX is not HTML. It is not a template language. It is syntactic sugar that your build toolchain transforms into ordinary JavaScript function calls. Understanding this transformation is the foundational step toward genuinely mastering React โ because it shapes how you think about component trees, rendering behavior, and the class of runtime errors that otherwise seem inexplicable.
What JSX Actually Is
When you write:
const element = <h1 className="title">Hello, React</h1>;
you are using a syntax extension invented by Facebook and later absorbed into the Babel ecosystem. JSX is not part of the ECMAScript specification. Browsers cannot parse it natively. It must pass through a compiler before it becomes executable JavaScript.
The design philosophy of JSX is to unify the expressive power of UI structure with the full logic of JavaScript in a single file. It deliberately retains an HTML-like appearance to keep component trees readable at a glance. But that readability is a surface-level affordance โ underneath, JSX is function calls, and function calls carry the full semantics of JavaScript.
The Old Transform: The createElement Era (React 16 and Earlier)
Before React 17, JSX compiled to React.createElement. This is precisely why every file using JSX had to begin with:
import React from 'react';
Even if your code never explicitly called React anywhere, the compiled output depended on it being in scope. Consider a typical transformation:
Source JSX:
function Welcome({ name }) {
return (
<div className="welcome">
<h1>Hello, {name}</h1>
<p>Welcome to React</p>
</div>
);
}
Classic transform output:
function Welcome({ name }) {
return React.createElement(
"div",
{ className: "welcome" },
React.createElement("h1", null, "Hello, ", name),
React.createElement("p", null, "Welcome to React")
);
}
The signature of React.createElement is:
React.createElement(type, props, ...children)
type: a string for a native DOM element, or a component function/classprops: an object of attributes, ornullif there are nonechildren: zero or more child nodes โ strings, numbers, other React elements, or arrays of those types
The function returns a React element โ a plain JavaScript object describing what you want rendered. It is not a DOM node. It is a description node on the virtual DOM tree.
The Internal Shape of a React Element
After React.createElement runs, you get an object that looks roughly like this:
{
$$typeof: Symbol(react.element),
type: "div",
key: null,
ref: null,
props: {
className: "welcome",
children: [
{
$$typeof: Symbol(react.element),
type: "h1",
props: { children: ["Hello, ", name] },
// ...
},
{
$$typeof: Symbol(react.element),
type: "p",
props: { children: "Welcome to React" },
// ...
}
]
},
_owner: null,
}
Pay attention to $$typeof. It is a Symbol, and its purpose is to prevent XSS attacks. JSON cannot serialize Symbols. If an attacker injects a user-supplied object that looks like a React element, React will refuse to render it because that object will never carry the correct $$typeof value. This is a security boundary built directly into the element shape.
The New Transform: The jsx() Factory (React 17+)
React 17 introduced the new JSX transform, a significant rework of the compilation pipeline. It has two core goals:
- Eliminate the implicit dependency on the
Reactnamespace, so JSX files no longer require a manual React import - Enable runtime performance optimizations by separating static and dynamic prop handling
The new transform changes the compilation target from React.createElement to _jsx and _jsxs, automatically imported from react/jsx-runtime.
Same source JSX:
function Welcome({ name }) {
return (
<div className="welcome">
<h1>Hello, {name}</h1>
<p>Welcome to React</p>
</div>
);
}
New transform output:
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
function Welcome({ name }) {
return _jsxs("div", {
className: "welcome",
children: [
_jsxs("h1", {
children: ["Hello, ", name]
}),
_jsx("p", {
children: "Welcome to React"
})
]
});
}
Two key differences to internalize:
_jsx vs _jsxs: jsx handles zero or one child, jsxs handles multiple children. This distinction allows the runtime to avoid an unnecessary array-wrapping check on every element creation call โ a small saving that compounds at scale.
children as a props field: The new transform inlines children directly into the props object rather than passing them as variadic arguments. This diverges from createElement's spread-argument design and enables better static analysis by compilers.
Configuring the Transform
In Babel, you control this through @babel/preset-react's runtime option:
{
"presets": [
["@babel/preset-react", {
"runtime": "automatic"
}]
]
}
"automatic" enables the new transform; "classic" retains the old behavior. SWC uses an equivalent configuration:
{
"jsc": {
"transform": {
"react": {
"runtime": "automatic"
}
}
}
}
Vite (which uses esbuild with optional SWC/Babel) defaults to the new transform. Create React App 4.0+ also enables it by default. Next.js has used it since version 11.
SWC vs Babel: The Speed Tradeoff
Historically, React projects used Babel for JSX transformation. Babel is implemented in JavaScript โ flexible and richly extensible, but speed-constrained. SWC (Speedy Web Compiler) is a Rust-based alternative that delivers 10โ70ร faster compilation on large codebases.
The two are semantically identical for JSX purposes โ the JavaScript they emit is functionally equivalent. The meaningful differences are:
- Plugin ecosystem: Babel has a far richer plugin catalog; SWC's plugin system is still maturing
- Debug information: Babel produces more granular source maps in certain edge cases
- Toolchain integration: Next.js 13+ defaults to SWC; Vite supports SWC via
@vitejs/plugin-react-swc
For most projects starting today, SWC is the right default. For projects with heavy Babel plugin usage (custom transforms, polyfills), Babel may still be the pragmatic choice.
React 19's Changes to the JSX Layer
React 19 introduces several adjustments at the JSX and element-creation level worth understanding carefully.
ref Is No Longer a Special Prop
In React 18 and earlier, ref was a reserved attribute excluded from props. Function components could not receive ref directly โ they needed forwardRef as a wrapper:
// React 18: forwardRef required
const Input = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
// Usage
<Input ref={inputRef} placeholder="Enter text" />
React 19 normalizes ref as an ordinary prop. The forwardRef wrapper is no longer needed:
// React 19: ref arrives as a plain prop
function Input({ ref, ...props }) {
return <input ref={ref} {...props} />;
}
// Usage is identical
<Input ref={inputRef} placeholder="Enter text" />
At the compilation level, this means ref is now included in the props object passed to jsx() rather than being extracted and handled separately. The reconciler still processes it specially โ attaching it to the underlying DOM node or component instance โ but the extraction now happens at reconciliation time, not at the JSX compile step.
forwardRef is deprecated in React 19 but still works; it will be removed in a future major version.
key Remains Exceptional
Unlike ref, key remains architecturally special. It never appears in the props object; it is passed as the third explicit argument to jsx():
// JSX: <li key={item.id}>{item.name}</li>
_jsx("li", { children: item.name }, item.id)
// ^^^^^^^ key as the third argument
This is intentional. key is metadata consumed by React's reconciliation algorithm to track element identity across renders. Components must never have access to key through props โ it would break the abstraction between rendering logic and reconciliation internals.
The Road to React Compiler
React 19 ships with the first production release of the React Compiler (previously called React Forget). This is relevant to the JSX layer because the compiler operates on JSX before standard Babel/SWC transformation.
The compiler analyzes your component code statically, identifies which values are stable across renders, and automatically inserts useMemo and useCallback equivalents. The new JSX transform (jsx() factory) was a prerequisite for this work โ its normalized, object-based props structure is far more amenable to static analysis than the variadic createElement signature.
You can enable the compiler in Babel:
{
"plugins": ["babel-plugin-react-compiler"]
}
Or in Vite:
// vite.config.js
import ReactCompiler from 'babel-plugin-react-compiler';
export default {
plugins: [
react({
babel: {
plugins: [ReactCompiler]
}
})
]
}
Verifying Your Mental Model: Read the Compiled Output
Build the habit of inspecting compiled output when you encounter confusing rendering behavior. The Babel REPL at babeljs.io/repl is the fastest way to do this interactively. Locally:
npx babel --presets @babel/preset-react src/Component.jsx
Understanding the compiled output answers questions like:
Why does {count && <Component />} sometimes render the number 0?
Because 0 is falsy in JavaScript, so the && short-circuits โ but 0 is a valid React child (it renders as the text "0"). The solution is {count > 0 && <Component />} or {!!count && <Component />}.
Why can't you use statements in JSX attribute positions? Because attribute values become function arguments in the compiled output, and arguments must be expressions, not statements.
Why must JSX have a single root element?
Because the compiled output is a single function call expression, and return accepts only one expression. Fragments โ <>...</> โ compile to _jsx(Fragment, { children: [...] }), which is itself a single expression.
The Big Picture
JSX is a compile-time syntactic decision, not a runtime mechanism. Every JSX expression is a function call. Every component tree is a tree of plain JavaScript objects (React elements). React's renderer walks this object tree, compares it against a previous snapshot, and surgically updates the DOM.
Grasping this foundation unlocks understanding of why rendering is "pure" in principle (same props should produce the same output), why React elements are immutable (they are descriptions, not live DOM nodes), and why diffing is efficient (comparing two object trees is orders of magnitude cheaper than comparing two DOM trees).
The new JSX transform is not merely a developer-experience improvement โ eliminating the boilerplate import React was a side effect, not the goal. The real motivation was architectural: normalizing the element creation API into a form that static analysis tools, bundle optimizers, and the React Compiler can reason about deeply. Every React version since 17 has been building toward a world where React can automatically memoize your components. JSX is where that pipeline begins.