Topic 1.7
JSX Under the Hood
In one line
The compiler turns each JSX tag into a jsx() call (or createElement in the classic runtime) that returns a frozen plain object, a React element with type, props and key, marked with a $$typeof Symbol so that JSON data can never pass itself off as an element.
Think of it like this
An order slip in a restaurant. The waiter writes "table 4: one dosa, extra chutney" on a slip and hands it to the kitchen. The slip is not the food; it is a description of what to make. Nobody changes a slip once it's handed over; a new order gets a new slip. A React element is that slip: a small object saying what should be on screen, which react-dom (the kitchen) turns into real DOM.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Automatic runtime
- The modern JSX setting: the compiler imports
jsxfromreact/jsx-runtimefor you. - Classic runtime
- The older setting: tags became
React.createElement(...), so every file imported React. - jsx() / jsxs()
- The functions tags compile to.
jsxsis used when a tag has several static children. - React element
- A frozen plain object
{ $$typeof, type, props, key }that describes one piece of UI. - type
- What an element renders: a tag name string like
"li"or a component function. - props.children
- Where the things between an element's tags are stored.
- key
- An element's identity among its siblings. Kept outside
props, always a string ornull. - $$typeof
- A Symbol marking a real React element. JSON can't contain Symbols, so it can't fake one.
- Object.freeze
- A JavaScript function that makes an object read-only.
Step by step
01Write a component with JSX
A component with nested tags, a variable inside braces, and a small list with a key.
function Greeting({ name }) {
return (
<div className="card">
<h1>Hello, {name}!</h1>
<p>Welcome back.</p>
</div>
);
}
const items = [<li key="a">A</li>, <Greeting name="Asha" />];02Read what the compiler made
This is the real output of sucrase (the compiler the live editor uses) for that file, with the automatic runtime. Every tag is a call; children went into props.children; the key became the third argument. Greeting is passed as the function itself, not a string.
import {jsxs as _jsxs, jsx as _jsx} from "react/jsx-runtime";
function Greeting({ name }) {
return (
_jsxs('div', { className: "card", children: [
_jsxs('h1', { children: ["Hello, " , name, "!"]})
, _jsx('p', { children: "Welcome back." })
]})
);
}
const items = [_jsx('li', { children: "A"}, "a"), _jsx(Greeting, { name: "Asha",} )];03Compare with the classic runtime
The same file compiled with the classic runtime. Children are extra arguments, and key sits inside the props. The React variable must be in scope, which is why old files began with import React from "react".
function Greeting({ name }) {
return (
React.createElement('div', { className: "card",}
, React.createElement('h1', null, "Hello, " , name, "!")
, React.createElement('p', null, "Welcome back." )
)
);
}
const items = [React.createElement('li', { key: "a",}, "A"), React.createElement(Greeting, { name: "Asha",} )];04The call returns an element object
jsx("h1", { className: "title", children: "Hello" }) returns roughly { $$typeof: Symbol(react.transitional.element), type: "h1", key: null, props: { className: "title", children: "Hello" } }. The production build's object also has a ref field; the development build adds _owner and _store and freezes it.
05Why the Symbol matters
Data from a server arrives as JSON text. JSON can hold strings, numbers, booleans, arrays and objects, but no Symbols. So an object parsed from JSON can copy every field of an element except the real $$typeof, and React refuses to render it as an element.
Try it yourself
- 1
Change the key to a number
In the first example, change
key="greeting"tokey={42}. Predict the second log line. (h1 | 42: keys are always stored as strings, so it's"42".) - 2
Count the calls
In the first example, add
console.log("Card called")insideCard, and log something right afterconst card = …. Which line prints first? (Your log after creating the element: creating<Card />doesn't callCard; rendering does.) - 3
Nested children
In the second example, log
list.props.children.lengthandlist.props.children[1].props.children. Predict both. (2andCoffee.)
Code & diagrams
An HTML element's `type` is a string; a component's `type` is the function itself. `key` is stored next to `props`, not inside them. `_owner` and `_store` are development-only fields. Creating `card` didn't call `Card`: React calls it later, when rendering.
Preview
Console
Nothing logged yet.Expected console output
object
h1 | greeting
{ className: 'title', children: 'Hello' }
[Function: Card] | null
{ title: 'Hi there' }
[ '$$typeof', 'type', 'key', 'props', '_owner', '_store' ]
Symbol(react.transitional.element)All three produce `type: "li"`, `key: "tea"` and the same props. With `jsx()` the key is the third argument; with `createElement` it goes in the props object and React moves it out. The page is built without any tags at all.
Preview
Console
Nothing logged yet.Expected console output
JSX -> li tea {"className":"item","children":"Tea"}
jsx() -> li tea {"className":"item","children":"Tea"}
createElement -> li tea {"className":"item","children":"Tea"}Assigning to a frozen element's props throws. `JSON.stringify` silently drops `$$typeof` because its value is a Symbol, and parsed JSON can only hold a string there, so `isValidElement` says `false`.
Preview
Console
Nothing logged yet.Expected console output
TypeError: Cannot assign to read only property 'className' of object '#<Object>'
title -> big
{"type":"h1","key":null,"props":{"className":"title","children":"Hello"},"_owner":null,"_store":{}}
symbol string
true falseBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Render an element that came from JSON
In the third example, render <div>{fake}</div> instead of bigger.
Break #2
Read key as a prop
Write function Card(props) { return <p>key is {String(props.key)}</p>; } and render <Card key="a1" title="x" />.
Myth vs fact
Myth
<Card /> calls the Card function.
Fact
It only creates an element object with type: Card. React calls Card later, during rendering, and may call it again (or, for skipped subtrees, not at all).
Myth
React elements are DOM elements.
Fact
They're plain JavaScript objects describing DOM elements. Creating thousands of them touches no DOM; react-dom creates or updates the real nodes during commit.
Myth
import React from "react" is needed in every JSX file.
Fact
Only with the classic runtime. The automatic runtime imports jsx for you, so current projects don't need it unless they use React.something directly.
Myth
$$typeof is just an internal detail.
Fact
It's a deliberate security check: because JSON can't contain Symbols, data from a server can never be mistaken for an element.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React 19 renamed the element marker to
Symbol.for("react.transitional.element")while changing the element's internals (string refs removed,refread from props). Mixing two React copies of different major versions in one app breaks for this reason: one copy doesn't recognise the other's elements. - ▸
Because
$$typeofusesSymbol.for, two copies of the same React version share the marker. Even so, two copies of React in one bundle cause "Invalid hook call" errors; check withnpm ls react. - ▸
In production
jsx()returns an unfrozen object{ $$typeof, type, key, ref, props }; freezing and the_owner/_store/debug fields exist only in development. That's why a mutation bug may throw in development and silently "work" (until it doesn't) in production. - ▸
Write
keybefore any spread.<li {...p} key="b" />makes the compiler fall back tocreateElement(sucrase emits_createElement('li', { ...p, key: "b" }, …)), because a later key must override the spread. Spreading an object that itself containskey(<li {...p} />withp.key) makes React 19 warn "A props object containing a \"key\" prop is being spread into JSX". The safe form is<li key={id} {...rest} />.
Remember this
- 1
With the automatic JSX runtime (the default since React 17 in every current tool), the compiler adds
import { jsx, jsxs } from "react/jsx-runtime"to your file and turns each tag into a call:jsx(type, props, key). The type is a string for HTML tags ("li") or your function for components (Card). The props object holds the attributes *and* the children, under the namechildren.jsxsis the same function used when there are several static children (written out in the code, not from an array), which tells React's development build not to ask for keys there. - 2
The classic runtime compiled tags to
React.createElement(type, props, ...children): the children are extra arguments andkeyis inside the props object.createElementstill exists in React 19 and returns the same kind of object, so old code keeps working. You can call it (orjsx()) yourself; the live examples below build identical elements all three ways. - 3
A React element is a plain object with a few fields.
type: what to render.props: the inputs, includingchildren.key: the identity used in lists (Topic 3.3), turned into a string (key={7}becomes"7"), ornull.keyis not part ofprops: React takes it out, and a component that readsprops.keygetsundefinedand a warning. In development the object also has bookkeeping fields such as_ownerand_storethat React uses for warnings and DevTools. - 4
Elements are immutable descriptions. In development builds React freezes each element and its props with
Object.freeze, soel.props.className = "big"throws aTypeErrorin strict-mode code (ES modules are strict). Creating an element doesn't render anything and doesn't call your component; it is cheap. To show something different, you create a new element, which is exactly what happens every render. - 5
Every element has a hidden marker, **
$$typeof, set to a Symbol**:Symbol(react.transitional.element)in React 19 (earlier versions usedSymbol(react.element)). A Symbol is a unique value that can't be written in JSON. That is a security feature: if a server returned user-controlled JSON shaped like an element ({ type: "script", props: … }) and your code rendered it, React would not accept it as an element, because JSON can only produce strings, never the real Symbol. It throws "Objects are not valid as a React child" instead.isValidElement(x)performs the same check. - 6
Putting it together: **JSX → compiler →
jsx()calls → element objects →react-dom→ DOM**. When a component renders, React compares the new element tree with the previous one bytypeandkey(reconciliation, Topic 0.2 and Phase 16) and updates only what changed. In Phase 24.1 you'll see how these short-lived elements map onto the long-lived fibers that hold state.
Explain it without notes
What does the compiler turn <li key="a" className="x">Hi</li> into with the automatic runtime, and with the classic runtime?
What fields does a React element have, and what does each mean?
Why are React elements immutable, and how do you show something different?
What is $$typeof, and what attack does it prevent?
What is the difference between jsx and jsxs?
Practice
Without using any tags, build and render <p className="note">Saved <b>3</b> files</p> with jsx/jsxs from react/jsx-runtime.
Log the type, key and props of <Avatar key="u1" size={40} />, where function Avatar() { return null; }. Predict the output first.
Trade-offs
- ↔
Calling
jsx()/createElementby hand avoids a compile step but is much harder to read and review; reserve it for tiny no-build pages or code that generates elements from data structures. - ↔
Development-only freezing and checks make mistakes loud but cost speed; never benchmark the development build (Phase 16).
Done when you can
I can read compiled JSX output for both runtimes.
I can name the fields of a React element and what they hold.
I know
keyisn't available inprops.I know elements are immutable and creating one doesn't call the component.
I can explain what
$$typeofprotects against.