JSX Is Just a Function Call
Every JSX element compiles to React.createElement. That explains the old import-React requirement and why react-dom is a separate package from react.
JSX looks like HTML. It is not HTML.
That distinction confused me for longer than I am comfortable admitting. I wrote JSX for years without understanding what it actually was -- just angle brackets that somehow worked inside JavaScript. It felt like magic.
It is not magic. It is a function call.
What JSX Compiles To
Every JSX element you write gets transformed before it ever reaches the browser. The compiler -- Babel or SWC, depending on your setup -- replaces each angle bracket with a call to React.createElement.
// What you write
const element = <button className="primary">Save</button>;
// What the compiler produces
const element = React.createElement('button', { className: 'primary' }, 'Save');The arguments are: the element type, the props object, and the children. That is all JSX is. A syntax that gets erased and replaced with function calls at build time.
Components work the same way:
// What you write
<UserCard name="James" role="admin" />
// What the compiler produces
React.createElement(UserCard, { name: 'James', role: 'admin' })When the first argument is a string like 'button', React knows to create a real DOM element. When it is a capitalized variable like UserCard, React knows to call that function as a component. That is the reason component names must start with an uppercase letter -- not convention, not style. The compiler uses capitalization to decide which branch to take.
Why You Used to Need import React from 'react'
Before React 17, every file that contained JSX needed this import at the top, even if you never used React directly in your code:
import React from 'react';
function Greeting() {
return <h1>Hello</h1>;
}The reason: the compiler was transforming <h1>Hello</h1> into React.createElement('h1', null, 'Hello'). For that to work at runtime, React had to be in scope. If you forgot the import, you got a cryptic "React is not defined" error even though you never typed React yourself.
React 17 introduced a new JSX transform that handles the import automatically. You no longer need it. But the mechanism is the same -- JSX still compiles to function calls. The new transform just uses a different internal import path so you do not have to manage it manually.
ExpandJSX source code passes through a Babel/SWC compiler and becomes a React.createElement call. Below that, the react package splits into two renderers: react-dom for the browser and react-native for iOS and Android.
React and ReactDOM Are Separate on Purpose
This is the part that clicked everything else into place for me.
There are two packages:
react-- the core library. Components, state, hooks, the reconciler.react-dom-- the browser renderer. Takes React's output and writes it to the DOM.
They are separate because React does not know what a browser is.
React produces a description of what the UI should look like -- a tree of React.createElement calls. That description is platform-agnostic. ReactDOM takes that description and translates it into actual DOM mutations. React Native takes the same description and translates it into native iOS and Android views.
Same React. Different renderer.
React.createElement('View', null, 'Hello')
|
├── react-dom → <div>Hello</div> (browser)
└── react-native → <View>Hello</View> (iOS / Android)This is why the architecture is split. React's job is to manage the component tree and figure out what changed. The renderer's job is to apply those changes to whatever environment it is targeting.
When you write:
import ReactDOM from 'react-dom/client';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);You are telling ReactDOM: "Take this React component tree, mount it to this DOM node, and keep it in sync from here on." React manages the tree. ReactDOM writes the pixels.
What This Means in Practice
Understanding this split has a practical payoff.
When you see errors about mismatched server/client rendering, the problem is ReactDOM's hydration logic -- not React itself. When React Native behaves differently from a web app, the difference is in the renderer, not the component model. When a library ships a react peer dependency, they mean the core -- the renderer you use is still your choice.
JSX is not HTML syntax bolted onto JavaScript. It is a compile target.
Every angle bracket is erased before your code runs. What remains are function calls that build a description of the UI -- and that description is what React works with, regardless of where it eventually gets rendered.
The next post covers the first React "gotcha" that hits almost everyone: why your useEffect appears to run twice on every mount in development, and why that is completely intentional.
The Essentials
- JSX compiles to
React.createElement(type, props, ...children)calls. There is no HTML in your JavaScript at runtime -- only function calls that build a UI description. - Component names must be uppercase because the compiler uses capitalization to distinguish between a string
'button'(native element) and a variableButton(component function). - React and ReactDOM are separate packages by design. React builds and manages the component tree. ReactDOM applies it to the browser DOM. React Native uses the same React with a different renderer -- that is the entire point of the split.
Further Reading and Watching
- What is JSX? -- Jack Herrington: A concise walkthrough of the JSX transform and what the compiler actually produces.
- Writing Markup with JSX -- React Docs: The official explanation of JSX syntax, rules, and why it exists.
Keep reading