React elements and the JSX runtime
Open the object every JSX tag becomes: jsx() and createElement at v19.3.1, each field of an element, why $$typeof blocks JSON XSS, and why elements are frozen.
- 01Trace a tag to an objectFollow JSX through jsx(), jsxs(), jsxDEV, or createElement to ReactElement, and say what each one does with the key and the children.
- 02Read every field of an elementExplain $$typeof, type, key, props, and ref in props, and the development-only _owner, _store, and debug fields.
- 03Explain the safety and the freezingSay why a symbol in $$typeof stops JSON from posing as an element, and why development builds freeze elements and their props.
What one JSX tag gives you
Every tag you write becomes an element: a small JavaScript object that describes one piece of the screen. You met elements in How React works and saw the jsx() call behind each tag in Writing markup with JSX. This lesson opens the object itself, at release v19.3.1, and reads the code that builds it. Start with one element, and every property it has:
const tea = <p key="tea" className="row">Green tea</p>; function describe(name, value) { if (typeof value === "symbol") return value.toString(); if (typeof value === "string") return JSON.stringify(value); if (value instanceof Error) return "an Error, with a stack"; if (name === "_debugTask") return value ? "a console task" : "null"; if (value && typeof value === "object") return "{" + Object.keys(value).map((key) => " " + key).join(",") + " }"; return String(value);} export default function App() { const listed = Object.keys(tea); return ( <> {tea} <table> <tbody> {Reflect.ownKeys(tea).map((name) => ( <tr key={name}> <th>{name}</th> <td>{describe(name, tea[name])}</td> <td>{listed.includes(name) ? "listed" : "hidden"}</td> </tr> ))} </tbody> </table> <p>Frozen: {String(Object.isFrozen(tea))}</p> </> );}Line 1 makes the element, outside any component. Line 16 renders it: the Green tea paragraph at the top of the result. Line 19 lists its own properties with Reflect.ownKeys, which includes hidden ones, and line 23 marks each one listed or hidden, depending on whether Object.keys (line 13) shows it (the Enumerability lesson explains hidden properties). Line 28 asks whether the object is frozen. The result has ten rows:
- Four that every element has:
$$typeof, a symbol;type, here"p";key, here"tea"; andprops, withclassNameandchildren. ref, hidden, andnullhere, since this tag has no ref.- Five that only development builds add:
_owner,_store,_debugInfo,_debugStack, and_debugTask. (Firefox has noconsole.createTask, so there_debugTaskisnull.)
And the last line says Frozen: true. The rest of the lesson explains every one of these.
jsx() call; the call returns an element, a plain object. Its four main fields are $$typeof, type, key, and props.A concert ticket tells the gate your seat. Anyone can photocopy one, so the gate looks for the hologram, which a copier can't reproduce. Laminating it means the seat printed on it is the seat you get.
- In real life: The printed seat, row, and show time
- In React: type, key, and props: what the element describes
- In real life: The hologram that only the box office can print
- In React: $$typeof, a symbol only code in your page can create
- In real life: A photocopy that shows every word but not the hologram
- In React: JSON, which keeps the fields but can't carry a symbol
- In real life: The ticket is laminated, so nobody can change the seat
- In React: Development builds freeze the element and its props
A React element is a plain, frozen object that describes one piece of UI. jsx() builds it with a type, a key, and props, and tags it with $$typeof, a symbol that JSON can't carry. React reads elements to decide what to render; nobody changes them after they're made.
jsx(), jsxs(), jsxDEV, and createElement
Four functions can make an element. To see which one runs, compile a small menu. Here is the JSX, a list with two items and a cart row that spreads an item object:
const menu = (
<ul className="menu">
<li key="tea">Green tea</li>
<li key="cake">Carrot cake</li>
</ul>
);
const row = <CartRow {...item} key={item.id} />;This is what Babel writes for a production build, with its line breaks changed to fit. TypeScript, esbuild, and Sucrase write the same calls; the lesson's test compiles the JSX again and compares.
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { createElement as _createElement } from "react";
const menu = _jsxs("ul", {
className: "menu",
children: [
_jsx("li", { children: "Green tea" }, "tea"),
_jsx("li", { children: "Carrot cake" }, "cake")
]
});
const row = _createElement(CartRow, { ...item, key: item.id });- Line 3: the
<ul>has two children written side by side, so the compiler calls_jsxs, the "s" for static children, an array the compiler wrote itself rather than one your code built. - Lines 6 and 7: each
<li>has one child, so it's_jsx. Look where the key went: out of the props object and into the third argument,"tea"and"cake". - Line 10: a key after a spread is a special case.
itemmight hold akeyof its own, and the order decides which one wins, so the compiler falls back tocreateElement, which reads the props in order.
In development mode, Babel writes one function for all three tags:
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
import { createElement as _createElement } from "react";
const menu = _jsxDEV("ul", {
className: "menu",
children: [
_jsxDEV("li", { children: "Green tea" }, "tea", false),
_jsxDEV("li", { children: "Carrot cake" }, "cake", false)
]
}, void 0, true);
const row = _createElement(CartRow, { ...item, key: item.id });Line 1 imports jsxDEV from react/jsx-dev-runtime. Its fourth argument says which of the other two it stands for: false for the items on lines 6 and 7, true for the list on line 9. The void 0 on line 9 means "no key". (TypeScript's development output passes two more arguments, the file name with the line and column, and this; React 19.3.1's jsxDEV takes four parameters, at line 441 of ReactJSXElement.js, and ignores the rest.)
Which function runs for jsx is decided when React is built, in ReactJSX.js:
const jsx: any = __DEV__
? jsxProdSignatureRunningInDevWithDynamicChildren
: jsxProd;
// we may want to special case jsxs internally to take advantage of static children.
// for now we can ship identical prod functions
const jsxs: any = __DEV__
? jsxProdSignatureRunningInDevWithStaticChildren
: jsxProd;
const jsxDEV: any = __DEV__ ? _jsxDEV : undefined;
export {REACT_FRAGMENT_TYPE as Fragment, jsx, jsxs, jsxDEV};- Lines 1 to 3: in a development build,
jsxis a function that records where the element was made, for warnings; in production, it'sjsxProd. - Lines 6 to 8:
jsxsis the same, except that development checks and freezes the static children array. - Line 10:
jsxDEVonly exists in development. In the production file it'sundefined, so code compiled for development crashes against a production React.
The tour of the React repository showed jsxProd in full. Whatever the door, every path ends in one function, ReactElement, and the elements come out the same. See it: this example makes one element three ways.
import { createElement } from "react";import { jsx } from "react/jsx-runtime"; const fromTag = <li key="tea" className="row">Green tea</li>;const fromJsx = jsx("li", { className: "row", children: "Green tea" }, "tea");const fromCreate = createElement("li", { key: "tea", className: "row" }, "Green tea"); const show = (element) => JSON.stringify({ type: element.type, key: element.key, props: element.props }); export default function App() { return ( <> <pre>{[fromTag, fromJsx, fromCreate].map(show).join("\n")}</pre> <ul>{fromTag}</ul> <ul>{fromJsx}</ul> <ul>{fromCreate}</ul> </> );}Line 4 is a tag. Line 5 calls jsx yourself, with the key as the third argument and the text as children. Line 6 calls createElement, with the key inside the props and the text as an extra argument. Line 8 prints each element's type, key, and props, and the three lines in the result are identical. So are the three list items under them.
jsx, jsxs, or, in development, jsxDEV; you can call createElement yourself. They differ in how they read the key and the children, and all four end in the same function, ReactElement, which builds the object.How to read the table: each row is one function, who calls it, and how it takes the key and the children.
| Function | Who calls it | Key and children |
|---|---|---|
jsx(type, props, key) | The compiler, for a tag with one child or none. | The key is the third argument; the child is props.children. |
jsxs(type, props, key) | The compiler, for a tag whose children are written side by side. | The same as jsx, with props.children an array the compiler wrote. In development, React freezes that array. |
jsxDEV(type, props, key, isStaticChildren) | The compiler in development mode, from react/jsx-dev-runtime. | The fourth argument says which of the two above it stands for. In production builds, jsxDEV is undefined. |
createElement(type, props, ...children) | You, by hand; or the compiler, when a key comes after a spread. | The key is inside props and is taken out; children are extra arguments. |
The fields of an element
ReactElement, at line 170 of ReactJSXElement.js, builds the object. It has a development branch and a production branch. Here are both, shortened:
if (__DEV__) {
// …
element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type,
key,
props,
// Record the component responsible for creating this element.
_owner: owner,
};
// … (ref, hidden, in development)
} else {
// In prod, `ref` is a regular property and _owner doesn't exist.
element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type,
key,
ref,
props,
};
}- Line 5:
$$typeof, the mark that says "this is an element". A later section is about it. - Lines 8 and 9:
typeandkey, exactly asjsx()received them, after the key became a string. - Line 11:
props, the object of everything else,childrenincluded. - Line 14, development only:
_owner, the component that was rendering when the element was made. React's warnings use it to name the component that wrote a bad element. - Lines 19 to 29, production: no
_owner, andrefis a plain field. That's all a production element has: five fields.
After this, the development branch adds _store, an object whose hidden validated field tracks whether React has checked this element's key, then _debugInfo (details for elements that came from Server Components), _debugStack (an Error made when the element was created, so error messages can show where), and _debugTask. All of them but _store are hidden, so tests and serializers that list properties don't see them.
Now look at different kinds of element. Click each button: the table shows the element's main fields, and the box under it renders the element.
import { Suspense, useRef, useState } from "react"; function CartRow({ name }) { return <p>{name}</p>;} function describe(element, name) { if (name === "ref") return "hidden (reading it warns)"; const value = element[name]; if (typeof value === "symbol") return value.toString(); if (typeof value === "function") return "function " + value.name; if (typeof value === "string") return JSON.stringify(value); if (name === "_owner") return value ? "the fiber of " + value.type.name : "null"; if (value instanceof Error) return "an Error, with a stack"; if (value && typeof value === "object") return "{ " + Object.keys(value).join(", ") + " }"; return String(value);} export default function App() { const input = useRef(null); const elements = { "<p>": <p key="tea" className="row">Green tea</p>, "<CartRow>": <CartRow name="Carrot cake" />, "<>…</>": <><p>Tea</p><p>Cake</p></>, "<Suspense>": <Suspense fallback="Loading…"><p>Lemonade</p></Suspense>, "<input ref>": <input ref={input} placeholder="Search" />, }; const [picked, setPicked] = useState("<p>"); const element = elements[picked]; return ( <> {Object.keys(elements).map((name) => ( <button key={name} aria-pressed={name === picked} onClick={() => setPicked(name)}>{name}</button> ))} <table> <tbody> {["$$typeof", "type", "key", "props", "_owner", "ref"].map((name) => ( <tr key={name}> <th>{name}</th> <td>{describe(element, name)}</td> </tr> ))} </tbody> </table> <div className="shown">{element}</div> </> );}Lines 21 to 27 make five elements, and line 38 lists six fields of the one you pick. What changes is type:
- <p>: a string,
"p", so React will make a DOM element. - <CartRow>:
function CartRow, your component itself, which React will call. - <>…</> and <Suspense>: symbols,
Symbol(react.fragment)andSymbol(react.suspense), from the same ReactSymbols.js. - <input ref>: the string
"input", andpropshasrefin it, next toplaceholder.
_owner says the fiber of App for all five, because App was rendering when line 21 ran. A fiber is React's record of one component on the page; the fiber lesson, next in this stage, opens it. Line 8 never reads ref, for a reason the next section shows.
How to read the table: each row is one field, what it holds, and whether a production element has it.
| Field | What it holds | In production? |
|---|---|---|
$$typeof | Symbol.for("react.transitional.element"), the mark that says this object is an element. | Yes. |
type | A tag name such as "p", your component's function, or a symbol for built-ins like Fragment and Suspense. | Yes. |
key | A string, or null when you gave none. | Yes. |
props | Everything else you wrote on the tag, children included, and ref since React 19. | Yes. |
ref | A copy of props.ref, or null. Hidden in development, where reading it warns if a ref was given. | Yes, as a plain field. |
_owner | The fiber of the component that was rendering when the element was made, or null. | No. |
_store | React's note about key warnings, in a hidden validated field. | No. |
_debugInfo | Debugging details for elements that came from Server Components, or null. | No. |
_debugStack, _debugTask | Where in your code the element was made, for owner stacks in errors and DevTools. | No. |
key leaves props, ref stays
key and ref look like props when you write them, but React treats them differently. Here is how jsxProd reads the key, at lines 300 to 320 of ReactJSXElement.js:
if (maybeKey !== undefined) {
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(maybeKey);
}
key = '' + maybeKey;
}
}
if (hasValidKey(config)) {
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
}- Lines 1 to 10: if the compiler passed a key as the third argument, line 8 turns it into a string with
'' + maybeKey. Sokey={3}becomes"3". - Lines 12 to 21: if the props object itself has a
key, as it does after a spread, line 19 uses that one instead. - Lines 2 to 3 and 13 to 14 handle an experimental key, and the
__DEV__blocks check that the key can become a string. Both are gone from the production file, as the source tour showed.
Then the props object is copied without key if it had one, so your component never receives it. In development, React goes one step further: it puts a hidden getter on props, so reading props.key returns undefined and prints a warning. ref is different. Since React 19, it's an ordinary prop and stays in props. The old element.ref still exists for older code, as a getter that warns, at lines 143 to 160:
function elementRefGetterWithDeprecationWarning() {
if (__DEV__) {
const componentName = getComponentNameFromType(this.type);
if (!didWarnAboutElementRef[componentName]) {
didWarnAboutElementRef[componentName] = true;
console.error(
'Accessing element.ref was removed in React 19. ref is now a ' +
'regular prop. It will be removed from the JSX Element ' +
'type in a future release.',
);
}
// An undefined `element.ref` is coerced to `null` for
// backwards compatibility.
const refProp = this.props.ref;
return refProp !== undefined ? refProp : null;
}
}Lines 4 and 5 warn once per component type, and lines 6 to 10 print the message. Lines 15 and 16 return the real value, which lives in props.ref. Try both in a cart row:
import { useRef, useState } from "react"; function CartRow(props) { const [report, setReport] = useState("not read yet"); return ( <> <p>{props.name}</p> <button onClick={() => setReport(String(props.key))}>Read props.key</button> <p>props.key inside CartRow: {report}</p> </> );} export default function App() { const rowRef = useRef(null); const row = <CartRow key="tea" name="Green tea" ref={rowRef} />; return ( <> <p>row.key: {JSON.stringify(row.key)}</p> <p>Object.keys(row.props): {Object.keys(row.props).join(", ")}</p> {row} </> );}Line 16 makes the row with a key, a name, and a ref. Line 20 shows row.key: "tea", and line 21 shows the props: name, ref, with no key. Now click Read props.key. Line 8 reads props.key inside CartRow, and the result says undefined. Press Edit & run to see the console: React printed CartRow: `key` is not a prop. Trying to access it will result in `undefined` being returned. If a component needs the value, pass it a second time under another name, like id.
<CartRow key="tea" name="Green tea" ref={rowRef} />, jsx() takes key out and stores it on the element as a string. name and, since React 19, ref stay in props, where your component can read them.$$typeof and the JSON attack
Why does every element carry a symbol? Because React treats objects with the right shape as elements, and some objects come from places you don't control. Picture a shop that shows product reviews. A review's text should be a string, and React escapes strings: a review saying <strong> shows those characters, never a bold tag (the Cross-site scripting lesson explains why escaping matters). But suppose the server has a bug that lets someone save an object as the text. The browser gets this, and the page renders it:
// The review, exactly as the shop's server sent it back.const response = '{"author":"Asha","text":{"type":"div","props":{"dangerouslySetInnerHTML":{"__html":"<strong>Free tea! Type your card number at evil.example</strong>"}}}}'; export default function App() { const review = JSON.parse(response); return ( <div className="review"> {review.author}: {review.text} </div> );}Line 2 is the server's response. Its text is shaped like an element: a type of "div", and props with dangerouslySetInnerHTML, the prop that inserts raw HTML. Line 5 parses it, and line 8 puts review.text on the page. React refuses. The result shows its error: Objects are not valid as a React child (found: object with keys {type, props}). If you meant to render a collection of children, use an array instead.
Here is what would happen without that check. Line 6 adds the one field JSON could never carry, by hand:
const response = '{"author":"Asha","text":{"type":"div","props":{"dangerouslySetInnerHTML":{"__html":"<strong>Free tea! Type your card number at evil.example</strong>"}}}}'; export default function App() { const review = JSON.parse(response); // Pretend JSON could carry the symbol, and add it by hand. review.text.$$typeof = Symbol.for("react.transitional.element"); return ( <div className="review"> {review.author}: {review.text} </div> );}Now React accepts the object as an element and renders the attacker's HTML: a bold line asking for a card number. A real attacker would add a script-running image or link. React 0.13 and older checked elements differently and were open to exactly this; React 0.14 added $$typeof to close it.
It works because of two facts about symbols (see Symbols). A symbol can't be written in JSON: JSON.stringify leaves symbol values out, and JSON.parse only makes strings, numbers, booleans, null, arrays, and objects. And Symbol.for("react.transitional.element") returns the same symbol everywhere in a page, even from two copies of React, because Symbol.for uses a shared registry. Here are React's symbols, at lines 14 to 18 of ReactSymbols.js:
// The Symbol used to tag the ReactElement-like types.
export const REACT_LEGACY_ELEMENT_TYPE: symbol = Symbol.for('react.element');
export const REACT_ELEMENT_TYPE: symbol = Symbol.for(
'react.transitional.element',
);Line 2 is the old mark, react.element, used up to React 18. Lines 3 to 5 are the one React 19 uses, react.transitional.element; the rename is in the 19.0 changelog. The check itself is isValidElement, which you can import from react, at lines 891 to 897:
export function isValidElement(object) {
return (
typeof object === 'object' &&
object !== null &&
object.$$typeof === REACT_ELEMENT_TYPE
);
}Three checks: an object (line 3), not null (line 4), with the symbol in $$typeof (line 5). The reconciler makes the same test while rendering children. An object that fails it reaches throwOnInvalidObjectTypeImpl in ReactChildFiber.js:
function throwOnInvalidObjectTypeImpl(returnFiber: Fiber, newChild: Object) {
if (newChild.$$typeof === REACT_LEGACY_ELEMENT_TYPE) {
throw new Error(
'A React Element from an older version of React was rendered. ' +
'This is not supported. It can happen if:\n' +
'- Multiple copies of the "react" package is used.\n' +
'- A library pre-bundled an old copy of "react" or "react/jsx-runtime".\n' +
'- A compiler tries to "inline" JSX instead of using the runtime.',
);
}
// $FlowFixMe[method-unbinding]
const childString = Object.prototype.toString.call(newChild);
throw new Error(
`Objects are not valid as a React child (found: ${
childString === '[object Object]'
? 'object with keys {' + Object.keys(newChild).join(', ') + '}'
: childString
}). ` +
'If you meant to render a collection of children, use an array ' +
'instead.',
);
}Lines 2 to 10 catch an element with the old mark first, and explain the usual causes: two copies of react, or a library with its own old JSX runtime. Lines 15 to 23 throw the error you saw, naming the object's keys (line 18).
null, arrays, and objects, but no symbols. So an object from JSON.parse can look like an element and still lack the one field that matters, and React throws instead of rendering it. Only code running in your page, like jsx(), can put the symbol there.Try other values. Pick a button or type JSON of your own, and the playground runs isValidElement's three checks and says what React does with the value as a child.
falsetypeof value === "object"yesvalue !== nullyesvalue.$$typeof === Symbol.for("react.transitional.element")no
As a child: React throws an Error
It's an object without the element symbol, so React refuses it and throws: Objects are not valid as a React child (found: object with keys {type, props}). If you meant to render a collection of children, use an array instead.
An element's shape and With $$typeof as text both fail the third check: a string "react.transitional.element" isn't the symbol. A string and A number aren't elements either, but they're valid children: React renders them as escaped text.
Elements are frozen descriptions
The Props lesson showed that development builds freeze props. They freeze the element too, at the end of ReactElement, in a __DEV__ block, at lines 276 to 279:
if (Object.freeze) {
Object.freeze(element.props);
Object.freeze(element);
}Line 2 freezes the props and line 3 the element, with Object.freeze. jsxs also freezes the static children array, and createElement freezes its children array. Try changing a badge after it's made:
const badge = <b className="badge">Cart: 2</b>;const menu = <ul><li>Tea</li><li>Cake</li></ul>; let result;try { badge.props.children = "Cart: 3"; result = "changed";} catch (error) { result = error.name;} export default function App() { return ( <> {badge} <p>Changing props.children: {result}</p> <p>Frozen: element {String(Object.isFrozen(badge))}, props {String(Object.isFrozen(badge.props))}, children array {String(Object.isFrozen(menu.props.children))}</p> </> );}Line 6 tries to change the badge's text. A module runs in strict mode, where writing to a frozen property throws, so line 9 records the error's name: TypeError. The badge still says Cart: 2. Line 17 checks all three objects: the element, its props, and the <ul>'s children array on line 2, made by jsxs. All three are frozen.
In production nothing is frozen, to save time, and the same assignment would quietly work. It would still be a bug, because React relies on elements staying the same. When a component renders again, React compares the new props with the ones it saw last time, and the first test is whether they're the same object. This is near the start of beginWork, at lines 4250 to 4262 of ReactFiberBeginWork.js:
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
// …
) {
// If props or context changed, mark the fiber as having performed work.
// This may be unset if the props are determined to be equal later (memo).
didReceiveUpdate = true;
} else {Lines 2 and 3 take the old props and the new ones. Line 6 compares the objects themselves, with !==. If you pass the same element again, its props are the same object, and when nothing else changed React skips work for it: a few lines further on, beginWork hands the fiber to attemptEarlyBailoutIfNoScheduledUpdate. That's why passing elements as children avoids renders. If elements could be changed in place, the same object could hold new content and React would skip a real change. So to change an element, make a new one: write the JSX again, or call cloneElement.
Rendering data from a server safely
The everyday lesson of $$typeof: data from a server is never an element, so render the parts you expect. Here a review list gets one good review and one whose text is an object:
import { isValidElement } from "react"; const reviews = JSON.parse('[{"id":1,"author":"Asha","text":"Lovely tea."},{"id":2,"author":"Ravi","text":{"type":"div","props":{"children":"Injected"}}}]'); function ReviewText({ text }) { if (typeof text === "string") return text; return <em>(this review couldn't be shown)</em>;} export default function App() { return ( <ul> {reviews.map((review) => ( <li key={review.id}> {review.author}: <ReviewText text={review.text} /> {" "}({isValidElement(review.text) ? "an element" : typeof review.text}) </li> ))} </ul> );}Line 3 parses two reviews. ReviewText (lines 5 to 8) renders the text only if it's a string, and a short note otherwise. Line 16 shows what isValidElement says about each text, next to its typeof: string for Asha's, object for Ravi's, never an element. Use isValidElement the other way round, for props that may hold either text or an element, such as an icon prop. Never use dangerouslySetInnerHTML on text from users.
"Green tea"3<b>Tea</b>[<li key="a">Tea</li>]nullfalseJSON.parse('{"type":"b","props":{}}'){ name: "Tea" }{ $$typeof: Symbol.for("react.element"), type: "b", key: null, props: {} }
Each card is something you might put inside braces, like {value}. Sort it by what React does with it.
Common mix-ups
- "An element is a DOM node." No. It's a plain object that describes one. React makes DOM nodes later, in the commit, and only for host elements like
<p>. - "An element is a component instance." No.
<CartRow />holds theCartRowfunction intype; nothing has called it yet. React calls it while rendering, and keeps its state on a fiber. - "key is a prop my component can read." No.
jsx()takes it out ofprops. Readingprops.keygivesundefinedand a warning. - "In React 19, refs live on element.ref." No.
refis a prop, inprops.ref.element.refis a deprecated copy that warns when read. - "$$typeof is just a type name." No. It's a security mark: a symbol, so JSON from a server can never fake it.
- "Elements aren't frozen in production, so changing them is fine there." No. React still assumes they never change, and a changed element can make it skip a real update.
Practice exercises
Each exercise is live: press Edit & run, change the code, and the result updates. Predict first, then check.
What does the page show: which keys does tea.props have?
const tea = <li key="tea" className="row">Green tea</li>;
export default function App() {
return <p>{Object.keys(tea.props).join(", ")}</p>;
}const tea = <li key="tea" className="row">Green tea</li>; export default function App() { return <p>{Object.keys(tea.props).join(", ")}</p>;}The page shows className, children. The compiler moved key to jsx()'s third argument, and the text between the tags became children.
Line 2 turns an element into JSON. Does the JSON contain $$typeof? Predict true or false.
const badge = <b>Cart: 2</b>;
const sent = JSON.stringify(badge);
export default function App() {
return <p>The JSON has $$typeof: {String(sent.includes("$$typeof"))}</p>;
}const badge = <b>Cart: 2</b>;const sent = JSON.stringify(badge); export default function App() { return <p>The JSON has $$typeof: {String(sent.includes("$$typeof"))}</p>;}The page shows false. JSON.stringify skips properties whose value is a symbol, so the JSON keeps type, key, and props but loses $$typeof. Parsing it back can never give you an element.
This code tries to change the badge's text, and silently fails. Make the page show Cart: 3 without changing the existing element. What does it show?
const badge = <b className="badge">Cart: 2</b>; export default function App() { try { badge.props.children = "Cart: 3"; } catch { // The change didn't happen. } return <p>{badge}</p>;}import { cloneElement } from "react"; const badge = <b className="badge">Cart: 2</b>; export default function App() { const updated = cloneElement(badge, {}, "Cart: 3"); return <p>{updated}</p>;}cloneElement makes a new element with the same type and props, and the new children you pass. The old badge is untouched; the page shows Cart: 3. Writing <b className="badge">Cart: 3</b> again works too.
Replace the JSX inside map with your own jsx() calls, already imported on line 1. What key does React see for the second row?
import { jsx } from "react/jsx-runtime"; const items = [ { id: "tea", name: "Green tea" }, { id: "mint", name: "Mint tea" },]; export default function App() { return <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;}import { jsx } from "react/jsx-runtime"; const items = [ { id: "tea", name: "Green tea" }, { id: "mint", name: "Mint tea" },]; export default function App() { const rows = items.map((item) => jsx("li", { children: item.name }, item.id)); return <ul>{rows}</ul>;}jsx("li", { children: item.name }, item.id) is exactly what the compiler writes for <li key={item.id}>{item.name}</li>. The page looks the same, and the second row's key is "mint".
Press Edit & run: the result shows an error instead of the list. Fix it so text that isn't a string shows (removed). What does Ravi's row show?
const reviews = JSON.parse('[{"id":1,"author":"Asha","text":"Lovely tea."},{"id":2,"author":"Ravi","text":{"type":"b","props":{"children":"Injected"}}}]');
export default function App() {
return (
<ul>
{reviews.map((review) => (
<li key={review.id}>
{review.author}: {review.text}
</li>
))}
</ul>
);
}const reviews = JSON.parse('[{"id":1,"author":"Asha","text":"Lovely tea."},{"id":2,"author":"Ravi","text":{"type":"b","props":{"children":"Injected"}}}]'); export default function App() { return ( <ul> {reviews.map((review) => ( <li key={review.id}> {review.author}: {typeof review.text === "string" ? review.text : "(removed)"} </li> ))} </ul> );}The check on line 8 lets only strings through, so React never meets the object and the list renders: Asha: Lovely tea. and Ravi: (removed).
What does the page show? Predict, then press Edit & run.
const banner = <b>Free delivery</b>;
function Cart() {
const badge = <b>3 items</b>;
return (
<p>
banner: {banner._owner ? banner._owner.type.name : "none"},
badge: {badge._owner ? badge._owner.type.name : "none"}
</p>
);
}
export default function App() {
return <Cart />;
}const banner = <b>Free delivery</b>; function Cart() { const badge = <b>3 items</b>; return ( <p> banner: {banner._owner ? banner._owner.type.name : "none"}, badge: {badge._owner ? badge._owner.type.name : "none"} </p> );} export default function App() { return <Cart />;}The page shows banner: none, badge: Cart. The banner was made at the top of the module, when nothing was rendering, so its _owner is null. The badge was made inside Cart, so its _owner is Cart's fiber. (_owner is a development-only field: read it to learn, never in an app.)
Check your understanding
Picture each tag as its jsx() call and the object it returns: most answers follow from that.
Question 1 of 8In React 19.3.1, what is an element's
$$typeof?Choose an answer to see the explanation.
Question 2 of 8Why can't a JSON response from a server pose as a React element?
Choose an answer to see the explanation.
Question 3 of 8What does
CartRowshow?Read the code, then predictfunction CartRow(props) { return <p>{String(props.key)}</p>; } export default function App() { return <CartRow key="tea" />; }Choose an answer to see the explanation.
Question 4 of 8Babel compiles
<li key="tea">Green tea</li>in development mode. Which call does it write?Choose an answer to see the explanation.
Question 5 of 8This runs in a development build. What does it show?
Read the code, then predictconst badge = <b>Cart: 2</b>; export default function App() { let result = "changed"; try { badge.props.children = "Cart: 3"; } catch (error) { result = error.name; } return <p>{result}</p>; }Choose an answer to see the explanation.
Question 6 of 8What does a compiler turn
const row = <CartRow {...item} key={item.id} />;into?Read the code, then predictJSXconst row = <CartRow {...item} key={item.id} />;Choose an answer to see the explanation.
Question 7 of 8Where does a
refyou pass to a component live on a React 19 element?Choose an answer to see the explanation.
Question 8 of 8React 19 throws "A React Element from an older version of React was rendered". What does it mean?
Choose an answer to see the explanation.
Key takeaways
- The compiler turns tags into
jsx,jsxs, orjsxDEVcalls, and falls back tocreateElementfor a key after a spread. All of them end inReactElement. - An element has
$$typeof,type,key, andprops, plusreffor older code. Development adds_owner,_store, and debug fields. keybecomes a string and leaves the props.refstays inpropssince React 19.$$typeofisSymbol.for("react.transitional.element"). JSON can't carry it, so server data can never pass as an element.- Development builds freeze elements, their props, and static children. Never change an element; make a new one.
Remember the one-liner.
An element is a frozen object with a symbol: React reads it, JSON can't fake it, and nobody changes it.
Coming next: Fiber: React's unit of work. Elements are thrown away every render; fibers are what React keeps.