cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Trace 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.
  • 02
    Read every field of an elementExplain $$typeof, type, key, props, and ref in props, and the development-only _owner, _store, and debug fields.
  • 03
    Explain 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:

Every property of one elementPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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"; and props, with className and children.
  • ref, hidden, and null here, since this tag has no ref.
  • Five that only development builds add: _owner, _store, _debugInfo, _debugStack, and _debugTask. (Firefox has no console.createTask, so there _debugTask is null.)

And the last line says Frozen: true. The rest of the lesson explains every one of these.

The tag p with key tea goes into the function jsx(). Out comes a window named element holding four cards: $$typeof, a symbol; type, "p"; key, "tea"; and props.<p key="tea" …>you writejsx()React buildselement$$typeof: Symbol(…)type: "p"key: "tea"props: { … }a plain, frozen object
You write a tag; the compiler turns it into a jsx() call; the call returns an element, a plain object. Its four main fields are $$typeof, type, key, and props.
Real-life analogyA ticket with a hologram

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
The main idea

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:

A menu and a cart row in JSXJSX
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.

The menu compiled for production (Babel)JavaScript
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 });
  1. 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.
  2. 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".
  3. Line 10: a key after a spread is a special case. item might hold a key of its own, and the order decides which one wins, so the compiler falls back to createElement, which reads the props in order.

In development mode, Babel writes one function for all three tags:

The same menu compiled for development (Babel)JavaScript
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:

What react/jsx-runtime exports, ReactJSX.js lines 17 to 28JavaScript
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};
  1. Lines 1 to 3: in a development build, jsx is a function that records where the element was made, for warnings; in production, it's jsxProd.
  2. Lines 6 to 8: jsxs is the same, except that development checks and freezes the static children array.
  3. Line 10: jsxDEV only exists in development. In the production file it's undefined, 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.

One element, three waysPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

Four function cards on the left: jsx(), jsxs(), jsxDEV(), and createElement(). Arrows from all four meet at one function card, ReactElement(), which points to a card named element.jsx()jsxs()jsxDEV()createElement()ReactElement()builds the objectelement
The compiler calls 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.

Four ways to make an element
FunctionWho calls itKey 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:

ReactElement builds the object, ReactJSXElement.js (shortened)JavaScript
  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,
    };
  }
  1. Line 5: $$typeof, the mark that says "this is an element". A later section is about it.
  2. Lines 8 and 9: type and key, exactly as jsx() received them, after the key became a string.
  3. Line 11: props, the object of everything else, children included.
  4. 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.
  5. Lines 19 to 29, production: no _owner, and ref is 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.

Inspect five kinds of elementPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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) and Symbol(react.suspense), from the same ReactSymbols.js.
  • <input ref>: the string "input", and props has ref in it, next to placeholder.

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

Every field of a React 19 element
FieldWhat it holdsIn production?
$$typeofSymbol.for("react.transitional.element"), the mark that says this object is an element.Yes.
typeA tag name such as "p", your component's function, or a symbol for built-ins like Fragment and Suspense.Yes.
keyA string, or null when you gave none.Yes.
propsEverything else you wrote on the tag, children included, and ref since React 19.Yes.
refA copy of props.ref, or null. Hidden in development, where reading it warns if a ref was given.Yes, as a plain field.
_ownerThe fiber of the component that was rendering when the element was made, or null.No.
_storeReact's note about key warnings, in a hidden validated field.No.
_debugInfoDebugging details for elements that came from Server Components, or null.No.
_debugStack, _debugTaskWhere 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:

jsxProd reads the key, ReactJSXElement.js lines 300 to 320JavaScript
  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;
    }
  }
  1. Lines 1 to 10: if the compiler passed a key as the third argument, line 8 turns it into a string with '' + maybeKey. So key={3} becomes "3".
  2. Lines 12 to 21: if the props object itself has a key, as it does after a spread, line 19 uses that one instead.
  3. 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:

element.ref warns in development, ReactJSXElement.js lines 143 to 160JavaScript
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:

Where key and ref end upPop out in the code editor (opens in a new tab)JSX
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}    </>  );}
ResultLoading…

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.

A window named JSX holds three cards: key tea, name Green tea, and ref rowRef. An arrow takes the key card to element.key. Another arrow takes name and ref into a window named props.JSXkey="tea"name="Green tea"ref={rowRef}element.key: "tea"taken out, a stringpropsname: "Green tea"ref: rowRefstays in props
From <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:

A review whose text is an object (an error on purpose)Pop out in the code editor (opens in a new tab)JSX
// 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>  );}
ResultLoading…

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:

The same review, if the symbol could travel in JSONPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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 element symbols, ReactSymbols.js lines 14 to 18JavaScript
// 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:

isValidElement, ReactJSXElement.js lines 891 to 897JavaScript
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:

React refuses an object child, ReactChildFiber.js lines 301 to 324JavaScript
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).

Left: JSON from a server goes through JSON.parse and becomes an object with type and props but no $$typeof; React refuses it. Right: jsx() makes an object with $$typeof set to the symbol; React renders it.From JSON'{"type":"div","props":…}'JSON.parse{ type, props }$$typeof: missingReact throws: not an elementFrom your codejsx("div", …){ type, props, … }$$typeof: Symbol(…)React renders it
JSON has strings, numbers, booleans, 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.

Playground: can JSON become an element?
isValidElement(JSON.parse(…))false
  1. typeof value === "object"yes
  2. value !== nullyes
  3. value.$$typeof === Symbol.for("react.transitional.element")no

As a child: React throws an Error

Try it yourself

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.

The three checks are isValidElement's, copied from ReactJSXElement.js at v19.3.1, and the error is the one React's reconciler throws. The lesson's test runs every example button through the real React and compares.

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:

ReactElement freezes in development, ReactJSXElement.js lines 276 to 279JavaScript
    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:

Changing an element after it's madePop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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:

beginWork compares the props objects, ReactFiberBeginWork.js (shortened)JavaScript
  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:

Show only text from a serverPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

What does React do with this child?
  • "Green tea"
  • 3
  • <b>Tea</b>
  • [<li key="a">Tea</li>]
  • null
  • false
  • JSON.parse('{"type":"b","props":{}}')
  • { name: "Tea" }
  • { $$typeof: Symbol.for("react.element"), type: "b", key: null, props: {} }
Try it yourself
0 of 9 correct

Each card is something you might put inside braces, like {value}. Sort it by what React does with it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 the CartRow function in type; 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 of props. Reading props.key gives undefined and a warning.
  • "In React 19, refs live on element.ref." No. ref is a prop, in props.ref. element.ref is 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.

Exercise 1 · Warm-upPredict the props

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>;
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upDoes the mark survive JSON?

    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>;
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeUpdate a frozen badge

      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>;}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeBuild the rows with jsx()

        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>;}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeKeep a bad review off the page

          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>
            );
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengePredict the owners

            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 />;
            }

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              Picture each tag as its jsx() call and the object it returns: most answers follow from that.

              The React elements quiz · 8 questionsScore: first tries count
              1. Question 1 of 8In React 19.3.1, what is an element's $$typeof?

                Choose an answer to see the explanation.

              2. Question 2 of 8Why can't a JSON response from a server pose as a React element?

                Choose an answer to see the explanation.

              3. Question 3 of 8What does CartRow show?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                function CartRow(props) {
                  return <p>{String(props.key)}</p>;
                }
                
                export default function App() {
                  return <CartRow key="tea" />;
                }

                Choose an answer to see the explanation.

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

              5. Question 5 of 8This runs in a development build. What does it show?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                const 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.

              6. Question 6 of 8What does a compiler turn const row = <CartRow {...item} key={item.id} />; into?

                Read the code, then predictJSX
                const row = <CartRow {...item} key={item.id} />;

                Choose an answer to see the explanation.

              7. Question 7 of 8Where does a ref you pass to a component live on a React 19 element?

                Choose an answer to see the explanation.

              8. 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, or jsxDEV calls, and falls back to createElement for a key after a spread. All of them end in ReactElement.
              • An element has $$typeof, type, key, and props, plus ref for older code. Development adds _owner, _store, and debug fields.
              • key becomes a string and leaves the props. ref stays in props since React 19.
              • $$typeof is Symbol.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.

              CompleteFrontend Clear concepts. Working examples.