Skip to content

JSX & Elements

JSX looks like HTML, but it is syntax sugar for regular JavaScript function calls. This:

const el = <button className="primary">Click</button>;

compiles, via the modern automatic JSX runtime, to a call into react/jsx-runtime. Here’s the actual output of the compiler (esbuild, jsx: 'automatic') run against that exact line — not a paraphrase:

import { jsx } from "react/jsx-runtime";
const el = /* @__PURE__ */ jsx("button", { className: "primary", children: "Click" });

The important consequence: JSX is an expression. It evaluates to a value you can store in a variable, return from a function, put in an array, or pass as a prop. It isn’t a template language with its own rules — it’s JavaScript, so { } inside JSX just drops back into a JS expression.

function List({ items, showHeader }) {
return (
<ul>
{showHeader && <li className="header">Items</li>} {/* JS expression */}
{items.map((item) => (
<li key={item.id}>{item.text}</li> {/* JS expression */}
))}
</ul>
);
}

What does jsx("button", ...) actually return? Not a DOM node — a plain JavaScript object describing what you want. Here’s a real one, inspected in a test rather than described in prose:

src/__tests__/element-is-an-object.test.tsx
import { expect, test } from 'vitest';
function Item(_props: { id: number }) {
return null;
}
test('a JSX element is a plain object: $$typeof, type, key, props', () => {
const el = <Item key="a" id={1} />;
expect(Object.keys(el).sort()).toEqual(['$$typeof', '_owner', '_store', 'key', 'props', 'type'].sort());
expect(String((el as unknown as { $$typeof: symbol }).$$typeof)).toBe('Symbol(react.transitional.element)');
expect(el.type).toBe(Item); // a function type for a component
expect(el.key).toBe('a'); // key lives on the element itself...
expect((el.props as { key?: unknown }).key).toBeUndefined(); // ...never inside props (next section)
expect(el.props).toEqual({ id: 1 });
});
✓ a JSX element is a plain object: $$typeof, type, key, props
Test Files 1 passed (1)
Tests 1 passed (1)

This is the key insight: a React element is a cheap, immutable description — not the real thing. Creating a thousand elements is just creating a thousand small objects ($$typeof, type, key, props, plus internal bookkeeping fields you don’t touch). React builds a tree of these descriptions on every render, then compares it to the previous tree to decide what actual DOM to touch (that’s reconciliation, next lesson).

flowchart LR
  jsx["JSX
<button>Click</button>"] -->|compiler| call["jsx('button', props)"]
  call --> obj["element object
{ type, props, key }"]
  obj --> tree["tree of elements
(the description)"]
  tree -->|React| dom["real DOM
(the result)"]
JSX to elements to DOM

The type field tells React what kind of node it is:

  • A string type ("button", "div") is a host element — React knows how to turn it into a real DOM node.
  • A function type (Counter, Greeting) is a component — React calls that function to get more elements, recursively, until it bottoms out in host elements.
<Counter />
// element: { type: Counter (the function), props: {} }
// React calls Counter() → gets more elements → repeats until only host elements remain

This is why a component name must be capitalized in JSX: <Counter /> (capital) means “call the Counter component,” while <counter /> (lowercase) means “create a host element literally named counter.” The capitalization is how the compiler decides between a string type and a variable reference.

key looks like just another attribute in JSX, but React strips it off the element before it ever reaches props. It exists purely for reconciliation (next lesson) — reading it from inside the component always returns undefined, and development builds warn if you try:

src/__tests__/key-is-not-a-prop.test.tsx
import { expect, test, vi } from 'vitest';
function Item(_props: { id: number }) {
return null;
}
test('reading props.key is always undefined and warns in development', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const el = <Item key="a" id={1} />;
const keyFromProps = (el.props as { key?: unknown }).key;
expect(keyFromProps).toBeUndefined();
expect(
spy.mock.calls.some(([msg]) => String(msg).includes('`key` is not a prop')),
).toBe(true);
spy.mockRestore();
});
✓ reading props.key is always undefined and warns in development
Test Files 1 passed (1)
Tests 1 passed (1)

The real React 19.3 warning text, captured verbatim from that test:

Item: `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)

If a component needs the same value the parent used for key, the parent must pass it again under a different prop name (e.g. id).

Before React 19, a plain function component could not receive ref at all — you had to wrap it in forwardRef to accept one. As of React 19, ref is an ordinary prop: any function component can declare a ref parameter and use it directly, no forwardRef required.

src/components/TextInput.tsx
import type { Ref } from 'react';
function TextInput({ ref, placeholder }: { ref?: Ref<HTMLInputElement>; placeholder?: string }) {
return <input ref={ref} placeholder={placeholder} />;
}
export default TextInput;
src/__tests__/ref-is-a-prop.test.tsx
import { createRef } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import TextInput from '../components/TextInput';
afterEach(cleanup);
test('a plain function component receives `ref` as a prop, no forwardRef needed', () => {
const ref = createRef<HTMLInputElement>();
render(<TextInput ref={ref} placeholder="hi" />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
expect(ref.current?.placeholder).toBe('hi');
});
✓ a plain function component receives `ref` as a prop, no forwardRef needed
Test Files 1 passed (1)
Tests 1 passed (1)

key and ref are therefore in two different categories: key is stripped out and never reaches props under any name; ref, since React 19, lives right inside props like any other value and can be destructured normally.

props.children is not always an array — its shape depends on how many children JSX sees:

src/__tests__/children-shapes.test.tsx
import { expect, test } from 'vitest';
test('children is undefined, a single value, or an array, depending on how many there are', () => {
const none = <input />;
const one = <div>Hi</div>;
const many = (
<div>
<span>A</span>
<span>B</span>
</div>
);
expect((none.props as { children?: unknown }).children).toBeUndefined();
expect((one.props as { children?: unknown }).children).toBe('Hi');
expect(Array.isArray((many.props as { children?: unknown[] }).children)).toBe(true);
expect((many.props as { children: unknown[] }).children).toHaveLength(2);
});
✓ children is undefined, a single value, or an array, depending on how many there are
Test Files 1 passed (1)
Tests 1 passed (1)

Code that assumes children is always an array (children.map(...)) breaks the moment a component is used with exactly one child — use Children.toArray (or Children.map) from react if you need a uniform shape.

Keep two things separate in your head: elements (the plain, cheap, immutable objects JSX produces every render) and components (the functions that get called to produce them). An element is a description, thrown away and recreated every render; a component instance — its state, its position in the tree — is what reconciliation (next lesson) works to preserve across those throwaway descriptions. key and ref both live outside the ordinary “props are just data” story because they’re inputs to that preservation machinery, not to your component’s rendering logic — which is exactly why key never reaches props at all, and why ref needed a special mechanism (forwardRef) until React 19 made it an ordinary prop.

ChoiceBenefitCost
JSX (compiles to jsx(type, props))Reads like markup, is genuinely a JS expression underneathRequires a build step (Babel/esbuild/SWC) — not valid JS on its own
Elements as plain objectsCheap to create in bulk; comparing old vs new tree is just object diffingNothing renders until React walks the tree — an element alone is inert
key stripped from propsReconciliation identity can’t be confused with ordinary render dataA component that legitimately needs the same value must receive it under a second, explicit prop name
ref as a plain prop (React 19)No forwardRef boilerplate for components that just need to expose a DOM nodeOlder codebases/tutorials still show forwardRef — worth recognizing both forms
  • “JSX is HTML, so browsers understand it directly.” Browsers never see JSX — it’s compiled away before ship, into jsx(type, props) calls, as proven above.
  • “A React element IS the DOM node.” It’s a plain object describing what the DOM node should look like. Nothing exists in the real DOM until React commits it (next lesson).
  • “props.key gives me the key I set for reconciliation.” It’s always undefined, and reading it warns in dev — proven above. Pass the same value again under a different prop name if you need it inside the component.
  • “You always need forwardRef to accept a ref in a function component.” Only true before React 19. Since 19, ref is a normal prop — proven above with zero forwardRef in sight.
  • “children is always an array, so .map is always safe.” Only with 2+ children. One child is the bare value; zero children is undefined — proven above.

When a generated component works with JSX elements directly, check for these before trusting it:

  • Does it try to read props.key (or destructure key) inside a component, expecting the value used in the parent’s key={...}? That’s always undefined — the value must be passed again under a different name.
  • Does it still wrap a component in forwardRef purely to accept a ref, when the codebase already targets React 19 (where ref is a plain prop)? Not wrong, but dated — worth flagging as simplifiable.
  • Does list-rendering code assume props.children is always an array (children.map(...), children[0]) without accounting for the single-child and no-children cases?
  • Does it treat an element object as if it were a live DOM node (reading .textContent, calling .addEventListener on it) instead of on a ref to the real node?
  • Is a lowercase custom tag used where a capitalized component was intended (<counter /> instead of <Counter />), silently creating an unknown host element instead of calling the component?

🐛 Spot the bug

function List({ items }) {
return (
<ul>
{items.map((item) => (
<ListItem key={item.id} {...item} />
))}
</ul>
);
}
function ListItem(props) {
// Trying to reuse the same id the parent used as the React key...
return <li data-id={props.key}>{props.label}</li>;
}
Show the answer

ListItem reads props.key expecting the same item.id the parent used as key={item.id} — but key is stripped off before props is built, so props.key is always undefined (and React warns about the read in development, as proven above). The rendered <li> gets data-id="undefined" for every row. The fix: have the parent pass the id again under its own name, e.g. <ListItem key={item.id} itemId={item.id} {...item} />, and read props.itemId inside ListItem.

What does JSX compile to?
What is a React element?
Why must a component be capitalized in JSX (<Counter /> not <counter />)?
What is k?
function Item(props: { id: number }) {
  return null;
}
const el = <Item key="a" id={1} />;
const k = (el.props as { key?: unknown }).key;
In React 19, does this compile and work WITHOUT wrapping TextInput in forwardRef?
function TextInput({ ref, placeholder }: { ref?: React.Ref<HTMLInputElement>; placeholder?: string }) {
  return <input ref={ref} placeholder={placeholder} />;
}
For <div><span>A</span><span>B</span></div>, what is props.children on the outer <div> element?