Command Palette

Search for a command to run...

Hectal
PHASE 1Beginner ~23 min· topic 7 of 7

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 jsx from react/jsx-runtime for you.
Classic runtime
The older setting: tags became React.createElement(...), so every file imported React.
jsx() / jsxs()
The functions tags compile to. jsxs is 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 or null.
$$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.

Greeting.jsxwhole filejsx
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.

Greeting.js (compiled, automatic runtime)whole filejavascript
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".

Greeting.js (compiled, classic runtime)whole filejavascript
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.

The call returns an element objectdiagram
Rendering diagram…

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.

Why the Symbol mattersdiagram
Rendering diagram…

Try it yourself

  1. 1

    Change the key to a number

    In the first example, change key="greeting" to key={42}. Predict the second log line. (h1 | 42: keys are always stored as strings, so it's "42".)

  2. 2

    Count the calls

    In the first example, add console.log("Card called") inside Card, and log something right after const card = …. Which line prints first? (Your log after creating the element: creating <Card /> doesn't call Card; rendering does.)

  3. 3

    Nested children

    In the second example, log list.props.children.length and list.props.children[1].props.children. Predict both. (2 and Coffee.)

Code & diagrams

Look inside real element objects New tab

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

Press Run to see the page.

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)
JSX, jsx() and createElement build the same element New tab

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

Press Run to see the page.

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"}
Frozen objects, and why JSON can't fake an element New tab

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

Press Run to see the page.

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 false

Break 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.

terminal
$ Console
── what you'll see ──
Uncaught Error: Objects are not valid as a React child (found: object with keys {$$typeof, type, props, key}). If you meant to render a collection of children, use an array instead.
An error occurred in the <div> component.

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" />.

terminal
$ Console
── what you'll see ──
Card: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)

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, ref read 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 $$typeof uses Symbol.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 with npm 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 key before any spread. <li {...p} key="b" /> makes the compiler fall back to createElement (sucrase emits _createElement('li', { ...p, key: "b" }, …)), because a later key must override the spread. Spreading an object that itself contains key (<li {...p} /> with p.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. 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 name children. jsxs is 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. 2

    The classic runtime compiled tags to React.createElement(type, props, ...children): the children are extra arguments and key is inside the props object. createElement still exists in React 19 and returns the same kind of object, so old code keeps working. You can call it (or jsx()) yourself; the live examples below build identical elements all three ways.

  3. 3

    A React element is a plain object with a few fields. type: what to render. props: the inputs, including children. key: the identity used in lists (Topic 3.3), turned into a string (key={7} becomes "7"), or null. key is not part of props: React takes it out, and a component that reads props.key gets undefined and a warning. In development the object also has bookkeeping fields such as _owner and _store that React uses for warnings and DevTools.

  4. 4

    Elements are immutable descriptions. In development builds React freezes each element and its props with Object.freeze, so el.props.className = "big" throws a TypeError in 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. 5

    Every element has a hidden marker, **$$typeof, set to a Symbol**: Symbol(react.transitional.element) in React 19 (earlier versions used Symbol(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. 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 by type and key (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

01

What does the compiler turn <li key="a" className="x">Hi</li> into with the automatic runtime, and with the classic runtime?

02

What fields does a React element have, and what does each mean?

03

Why are React elements immutable, and how do you show something different?

04

What is $$typeof, and what attack does it prevent?

05

What is the difference between jsx and jsxs?

Practice

01

Without using any tags, build and render <p className="note">Saved <b>3</b> files</p> with jsx/jsxs from react/jsx-runtime.

02

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()/createElement by 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 key isn't available in props.

  • I know elements are immutable and creating one doesn't call the component.

  • I can explain what $$typeof protects against.