React
JSX from Syntax to Runtime
June 13, 2026
I was using JSX for years before I looked at what it compiled to. It reads like HTML, but it is JavaScript all the way down — and several syntax rules exist only because a compiler sits between your editor and the runtime.
This post covers what JSX is, how it transforms at build time, the rules worth memorizing, and the edge cases that still trip me up in real codebases.
In this post:
- What JSX is and why it exists
- Classic
createElementvs the automatic JSX runtime - Syntax rules, common patterns, and TypeScript typing
- Gotchas (
className, fragments,&&with zero, inline handlers)
What is JSX?
The short answer
JSX is syntactic sugar for function calls that describe UI. It looks like HTML, but it is JavaScript.
With the classic transform, those calls are React.createElement():
// JSX syntax
const element = <h1>Hello, World!</h1>;
// Compiles to (classic runtime)
const element = React.createElement("h1", null, "Hello, World!");The compiler (Babel, TypeScript, or SWC) transforms JSX before your bundle runs.
Why JSX exists
Without JSX (verbose, easy to mis-nest):
const element = React.createElement(
"div",
{ className: "greeting", id: "main" },
React.createElement("h1", null, "Hello"),
React.createElement("p", null, "Welcome!"),
);With JSX (declarative, structure matches the tree):
const element = (
<div className="greeting" id="main">
<h1>Hello</h1>
<p>Welcome!</p>
</div>
);JSX keeps nesting visible and matches how you think about component trees.
How JSX compiles
The transformation pipeline
Original JSX:
function Greeting({ name }: { name: string }) {
return (
<div className="container">
<h1>Hello, {name}!</h1>
<p>Welcome back.</p>
</div>
);
}Step 1 — Parse. The compiler turns JSX into an AST (abstract syntax tree).
Step 2 — Transform. With the classic runtime, the AST becomes createElement calls:
function Greeting({ name }: { name: string }) {
return React.createElement(
"div",
{ className: "container" },
React.createElement("h1", null, "Hello, ", name, "!"),
React.createElement("p", null, "Welcome back."),
);
}Step 3 — Execute. At runtime, createElement (or the automatic runtime helpers) returns a plain object React uses during reconciliation — often called a React element:
// Shape returned by createElement (simplified)
{
$$typeof: Symbol.for('react.element'),
type: 'div',
props: {
className: 'container',
children: [
{ type: 'h1', props: { children: ['Hello, ', 'Alice', '!'] } },
{ type: 'p', props: { children: 'Welcome back.' } },
],
},
}Automatic JSX runtime (React 17+)
Most apps today use the automatic transform. You do not need import React from 'react' just to write JSX, and the compiler emits calls to jsx / jsxs from react/jsx-runtime instead of React.createElement:
// Compiles to (automatic runtime, simplified)
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
// Same JSX
function Greeting({ name }: { name: string }) {
return (
<div className="container">
<h1>Hello, {name}!</h1>
<p>Welcome back.</p>
</div>
);
}
function Greeting({ name }) {
return _jsxs("div", {
className: "container",
children: [
_jsx("h1", { children: ["Hello, ", name, "!"] }),
_jsx("p", { children: "Welcome back." }),
],
});
}The mental model is the same: JSX becomes function calls that return React elements. Only the import path and helper names changed.
createElement signature (classic runtime)
// Under the hood, JSX calls this:
React.createElement(
type, // 'div', Component, Fragment, etc.
props, // Object with attributes/event handlers
...children // Variable number of children
)
// Returns:
{
type,
props: { ...props, children },
key,
ref,
// ... other internal fields
}JSX → createElement mapping
| JSX | createElement |
|---|---|
<div/> | React.createElement("div") |
<Component /> | React.createElement(Component) |
<div className="foo">Hello</div> | React.createElement("div", {className: "foo" }, "Hello") |
<div>{children}</div> | React.createElement("div", null, children) |
<div>A {expr} B</div> | React.createElement("div", null, "A ", expr, " B") |
Syntax rules
Rule 1: Children live in props.children
// ❌ WRONG: You can't access children like props.name
function Component(props) {
return <div>{props.name}</div>; // ✓ name is a prop
}
// ✓ CORRECT: Children are in props.children
function Component(props) {
return <div>{props.children}</div>;
}
// Better: Destructure
function Component({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// Usage:
<Component>This is children</Component>;Under the hood:
// This JSX:
<Component>Hello</Component>;
// Compiles to:
React.createElement(Component, null, "Hello");
// Which means props = { children: 'Hello' }Rule 2: Props are attributes, handlers, or React-specific props
// HTML Attributes
<div className="red" id="main" data-testid="header" />
// Event Handlers (camelCase!)
<button onClick={handleClick} onMouseEnter={handleHover} />
// Special Props (React-specific)
<Component key="unique-id" ref={myRef} />
// Custom Props (passed to component)
<Component isLoading={true} userName="Alice" theme="dark" />Rule 3: Capitalization matters
// ✓ Lowercase = HTML element
<div>This is a div</div>
<span>This is a span</span>
// ✓ Capitalized = React Component
`<Component />`
`<MyButton />`
`<UserProfile />`
// ❌ This is a BUG (React treats it as HTML element, not component!)
const myComponent = () => <div>Hello</div>;
<myComponent /> // Wrong! React looks for <mycomponent> HTML element
// ✓ CORRECT: Assign to capitalized variable
const MyComponent = () => <div>Hello</div>;
<MyComponent /> // Correct!
// Why? Babel uses this rule:
// - Lowercase with dots (foo.bar) → dynamic import
// - Lowercase without dots (foo) → HTML element string
// - Capitalized (Foo) → component referenceRule 4: Curly braces take expressions, not statements
// ✓ VALID: Expressions evaluate to values
<div>{name}</div>
<div>{count + 1}</div>
<div>{isReady ? 'Ready' : 'Loading'}</div>
<div>{items.map(item => <li key={item.id}>{item.name}</li>)}</div>
// ❌ INVALID: if/for statements (not expressions)
<div>
{if (isReady) { return 'Ready'; }} // ❌ Syntax error!
</div>
// ✓ VALID: Use conditional expressions instead
<div>
{isReady ? 'Ready' : 'Loading'}
</div>
// ❌ INVALID: Objects (must serialize or destructure)
<div>{name: 'Alice', age: 30}</div> // ❌ Error!
// ✓ VALID: Serialize or pick properties
<div>{JSON.stringify(user)}</div>
<div>{user.name}, {user.age}</div>
// ❌ INVALID: Functions aren't rendered
<div>{() => 'text'}</div> // ❌ Shows "[Function]"
// ✓ VALID: Call the function
<div>{getGreeting()}</div>
// ❌ INVALID: Promises aren't rendered (yet—Suspense changes this)
<div>{fetchData()}</div> // ❌ Shows "[object Promise]"
// ✓ VALID: Use useEffect to handle promise
useEffect(() => {
fetchData().then(data => setData(data));
}, []);Rule 5: Props are read-only
// ✓ Props are read-only
function Component({ count }: { count: number }) {
return <div>{count}</div>;
}
// ❌ WRONG: Don't modify props
count = count + 1; // ❌ Error! const
// ✓ CORRECT: Use state for mutation
const [count, setCount] = useState(0);
<div onClick={() => setCount(count + 1)}>{count}</div>;Rule 6: null, undefined, true, and false render nothing
// These render nothing:
<div>{null}</div> // Empty
<div>{undefined}</div> // Empty
<div>{true}</div> // Empty
<div>{false}</div> // Empty
// ✓ CORRECT: Render with &&
<div>{isReady && 'Ready'}</div>
// ❌ WRONG: This renders "false"
<div>{isReady || 'Loading'}</div> // If false, shows "Loading" ✓
<div>{!isReady && 'Loading'}</div> // Better: shows nothing if true
// ⚠️ GOTCHA: Be careful with && and falsy numbers
<div>{count && <Items count={count} />}</div>
// If count = 0, renders "0" (not empty!)
// ✓ FIX: Use explicit check
<div>{count > 0 && <Items count={count} />}</div>Common patterns
Conditional rendering
// ✓ Ternary (best for binary choice)
<div>
{isLoading ? <LoadingSpinner /> : <Content />}
</div>
// ✓ && (for show/hide)
<div>
{isReady && <ReadyContent />}
</div>
// ✓ if/else outside JSX (for complex logic)
let content;
if (isLoading) {
content = <LoadingSpinner />;
} else if (error) {
content = <ErrorMessage error={error} />;
} else {
content = <Content />;
}
return <div>{content}</div>;
// ✓ Switch/case (for multiple states)
const getContent = () => {
switch (status) {
case 'loading':
return <LoadingSpinner />;
case 'error':
return <ErrorMessage />;
case 'success':
return <Content />;
}
};
return <div>{getContent()}</div>;Rendering lists
// ✓ CORRECT: Use .map() with unique key
function UserList({ users }: { users: User[] }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// ❌ WRONG: Using index as key
{
users.map((user, index) => <li key={index}>{user.name}</li>);
}
// ✓ Filter within map
{
users
.filter((user) => user.isActive)
.map((user) => <li key={user.id}>{user.name}</li>);
}
// ✓ Complex filtering
{
users
.filter((user) => user.age > 18)
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => <li key={user.id}>{user.name}</li>);
}
// ⚠️ GOTCHA: Creating arrays in JSX
{
user.roles.map((role) => <Tag key={role}>{role}</Tag>);
}
// If role is string, this works. If it's object, include an ID!Dynamic props
// ✓ Spread operator
<Component {...props} />
// ✓ Conditional props
<button disabled={isLoading} onClick={handleClick}>
{isLoading ? 'Loading...' : 'Submit'}
</button>
// ✓ Dynamic className
<div className={`btn ${isActive ? 'active' : ''}`}>Button</div>
// ✓ Dynamic style object
<div style={{
color: isDark ? 'white' : 'black',
padding: spacing * 2
}}>
Content
</div>
// ✓ Dynamic event handlers
const handler = isEditing ? handleSave : handleEdit;
<button onClick={handler}>
{isEditing ? 'Save' : 'Edit'}
</button>Composition
// ✓ Children pattern
function Container({ children }: { children: React.ReactNode }) {
return <div className="container">{children}</div>;
}
<Container>
<Header />
<Content />
<Footer />
</Container>;
// ✓ Slots pattern (multiple children)
function Layout({
sidebar,
main,
footer,
}: {
sidebar: React.ReactNode;
main: React.ReactNode;
footer: React.ReactNode;
}) {
return (
<div className="layout">
<div className="sidebar">{sidebar}</div>
<div className="main">{main}</div>
<div className="footer">{footer}</div>
</div>
);
}
<Layout sidebar={<Sidebar />} main={<MainContent />} footer={<Footer />} />;
// ✓ Render props pattern
function DataProvider({
children,
}: {
children: (data: Data) => React.ReactNode;
}) {
const [data, setData] = useState<Data | null>(null);
useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data && children(data)}</div>;
}
<DataProvider>{(data) => <UserProfile user={data} />}</DataProvider>;TypeScript and JSX
Basic type safety
// ✓ Typed props
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
function Button({ label, onClick, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
// ✓ Usage is type-safe
<Button label="Click me" onClick={() => console.log('clicked')} />
// ❌ TypeScript error:
<Button label="Click me" onClik={() => {}} /> // Property 'onClik' does not exist
// ✓ React.ReactNode for children
interface ComponentProps {
children?: React.ReactNode;
title: string;
}
function Card({ children, title }: ComponentProps) {
return (
<div>
<h2>{title}</h2>
{children}
</div>
);
}Typing event handlers
// ✓ Specific event types
function TextInput() {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("clicked");
};
return (
<>
<input onChange={handleChange} />
<form onSubmit={handleSubmit}>
<button onClick={handleClick}>Submit</button>
</form>
</>
);
}
// Common event types:
// React.ChangeEvent<T> - onChange
// React.FormEvent<T> - onSubmit
// React.MouseEvent<T> - onClick, onHover
// React.KeyboardEvent<T> - onKeyDown, onKeyUp
// React.FocusEvent<T> - onFocus, onBlurGeneric components
// ✓ Generic component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// ✓ Usage with type inference
<List
items={[
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]}
renderItem={(user) => `${user.name}`}
keyExtractor={(user) => user.id}
/>;
// Without explicit type, TypeScript infers:
// T = { id: number; name: string }Prop spreading
// ✓ Forward all props
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary";
}
function Button({ variant = "primary", ...props }: ButtonProps) {
return <button className={`btn-${variant}`} {...props} />;
}
// ✓ Usage preserves all standard button props
<Button
variant="primary"
onClick={() => {}}
disabled={true}
type="submit"
className="custom"
/>;
// ✓ Extract known props, pass rest
interface CardProps {
title: string;
children?: React.ReactNode;
// Rest are HTML div attributes
}
function Card({
title,
children,
...divProps
}: CardProps & React.HTMLAttributes<HTMLDivElement>) {
return (
<div {...divProps}>
<h2>{title}</h2>
{children}
</div>
);
}Edge cases and gotchas
Fragment shorthand vs full syntax
// ✓ Shorthand (recommended)
<>
<h1>Title</h1>
<p>Content</p>
</>
// ✓ Full syntax (needed with key or props)
<React.Fragment key="section1">
<h1>Title</h1>
<p>Content</p>
</React.Fragment>
// ✓ With keys (can't use shorthand)
{items.map(item => (
<React.Fragment key={item.id}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</React.Fragment>
))}
// Compiles to:
React.createElement(React.Fragment, null, ...)Strings vs numbers (and the && trap)
// ✓ String is rendered as text
<div>Hello</div>
// ✓ Number is rendered as text
<div>{42}</div>
// ⚠️ But be careful with 0
{count === 0 && <div>Count is zero</div>} // Shows nothing ✓
// ❌ This shows "0"
{count && <div>Count is {count}</div>} // If count = 0, shows "0" ✗
// ✓ Fix with > 0
{count > 0 && <div>Count is {count}</div>}class vs className
// ❌ WRONG: class is reserved JavaScript keyword
<div class="container"> // ❌ This won't work in JSX
// ✓ CORRECT: Use className
<div className="container"> // ✓
// Why? Because JSX is JavaScript, and class is a keyword.
// className gets compiled to the class prop.The style prop
// ❌ WRONG: String (in HTML you'd do style="color: red")
<div style="color: red"> // ❌ Won't work!
// ✓ CORRECT: Object with camelCase properties
<div style={{ color: 'red', fontSize: '16px' }}>
Content
</div>
// ✓ Or define outside
const styles = {
container: {
color: 'red',
fontSize: '16px',
}
};
<div style={styles.container}>Content</div>
// ⚠️ Units: Numbers are assumed to be px for most properties
<div style={{ padding: 16 }}> // 16px
<div style={{ opacity: 0.5 }}> // 0.5 (unitless)
<div style={{ zIndex: 10 }}> // 10 (unitless)SVG attributes
// ❌ WRONG: SVG uses different attribute names
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" stroke="black" strokeWidth="3" />
</svg>
// SVG attributes are camelCased in JSX:
// stroke-width → strokeWidth
// stroke-linecap → strokeLinecap
// fill-rule → fillRule
// xml:lang → xmlLanghtmlFor on labels
// ❌ WRONG: for is a keyword
<label for="username">Username</label>
// ✓ CORRECT: Use htmlFor
<label htmlFor="username">Username</label>
<input id="username" />Comments in JSX
// ✓ CORRECT: {} outside comments don't work in JSX
{/* This is a comment */}
// ❌ WRONG: // comments in JSX break things
<div>
// This breaks the JSX! ❌
</div>
// ✓ CORRECT: Use {} for multiline comments
<div>
{/*
Multiline comment
in JSX works fine
*/}
Content
</div>Empty components
// ❌ WRONG: Returning nothing
function Component() {
return; // ❌ Returns undefined, not empty JSX
}
// ✓ CORRECT: Return null or empty fragment
function Component() {
return null; // ✓ Renders nothing
}Inline functions in event handlers
// ✓ Arrow function (creates new function each render, but fine for events)
<button onClick={() => handleClick(id)}>Click</button>
// ✓ Or use useCallback to prevent re-renders of memoized children
const handleClick = useCallback(() => {
console.log(id);
}, [id]);
<button onClick={handleClick}>Click</button>
// ❌ DON'T: Call function immediately
<button onClick={handleClick()}>Click</button> // ❌ Calls handleClick on render!
// ❌ DON'T: Pass arguments directly without callback
<button onClick={handleDelete(item.id)}>Delete</button> // ❌ Calls on render!
// ✓ CORRECT: Wrap in callback
<button onClick={() => handleDelete(item.id)}>Delete</button>
Intrinsics and components
Intrinsic elements (built-in HTML)
// Intrinsic elements — lowercase tags React maps to DOM nodes
<div />
<span />
<button />
<input />
<p />
// React knows their props; TypeScript uses JSX.IntrinsicElementsCustom components
// Components you define — capitalized identifiers
<MyButton />
<Card />
<UserProfile />
// React calls your function with props; TypeScript uses your props interfacePolymorphic components
// "as" pattern: Component that can be any element type
interface PolymorphicProps {
as?: React.ElementType;
children?: React.ReactNode;
className?: string;
}
function Box({ as: Component = 'div', className, children }: PolymorphicProps) {
return <Component className={className}>{children}</Component>;
}
// Usage:
<Box>Default (renders as div)</Box>
<Box as="section">Renders as section</Box>
<Box as="article">Renders as article</Box>
// Advanced: Type-safe polymorphic component
type PolymorphicPropsWithRef<E extends React.ElementType> =
React.ComponentProps<E> & {
as?: E;
};
function Button<E extends React.ElementType = 'button'>(
{ as, ...props }: PolymorphicPropsWithRef<E>
) {
const Component = as || 'button';
return <Component {...props} />;
}
// Now typing is preserved:
<Button onClick={() => {}}>Button</Button> // button props
<Button as="a" href="/home">Link Button</Button> // a propsPerformance
Re-renders and referential identity
Inline arrow functions in event handlers are fine for most components. Reach for useCallback when profiling shows a memoized child re-rendering unnecessarily, not by default.
// Usually fine — new function each render, but cheap for a native <button>
<button onClick={() => handleClick(id)}>Click</button>
// Worth memoizing when the child is wrapped in React.memo and re-renders are costly
const handleClickWithId = useCallback(() => {
handleClick(id);
}, [id]);
<MemoizedButton onClick={handleClickWithId}>Click</MemoizedButton>
// ❌ Inline objects/arrays passed to memoized children — new reference every render
<MemoizedCard style={{ color: 'red' }} />
// ✓ Hoist or memoize when it matters
const style = useMemo(() => ({ color: 'red' }), []);
<MemoizedCard style={style} />
// ✓ Stable array reference — hoist literals outside render when reused
const numbers = [1, 2, 3];
{numbers.map(i => <Item key={i} number={i} />)}Conditional rendering and mount cost
// Both are idiomatic; pick based on readability
{
isReady ? <Content /> : <Spinner />;
}
{
isReady && <Content />;
}
// With &&, React only mounts <Content /> when isReady is true.
// Prefer && when the fallback UI is "render nothing" rather than an alternate branch.
{
isReady && <ExpensiveComponent />;
}Best practices
Keep JSX readable
// ❌ TOO NESTED
return (
<div>
{data ? (
<section>
{items.length > 0 ? (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
) : (
<p>No items</p>
)}
</section>
) : (
<p>Loading...</p>
)}
</div>
);
// ✅ EXTRACTED TO VARIABLES/FUNCTIONS
const renderContent = () => {
if (!data) return <p>Loading...</p>;
if (items.length === 0) return <p>No items</p>;
return <ItemList items={items} />;
};
return <section>{renderContent()}</section>;Name components clearly
// ✓ Capitalize component names
const UserCard = () => <div>User</div>;
// ✓ Descriptive names
const LogoutButton = () => <button>Logout</button>;
const UserAvatarWithName = () => <div>User</div>;
// ✓ Use verb-noun pattern for data fetching
const UserProfileWithData = () => {
const { data } = useQuery(...);
return <UserProfile user={data} />;
};Separate data and presentation
// ✓ Presentational component (pure)
interface UserProfileProps {
user: User;
onEdit: () => void;
}
const UserProfile = ({ user, onEdit }: UserProfileProps) => (
<div>
<h1>{user.name}</h1>
<button onClick={onEdit}>Edit</button>
</div>
);
// ✓ Container component (smart, with data)
const UserProfileContainer = ({ userId }: { userId: string }) => {
const { data: user } = useQuery([userId], () => fetchUser(userId));
const handleEdit = () => {
// Edit logic
};
return <UserProfile user={user} onEdit={handleEdit} />;
};Type your props
// ✓ Always use interfaces or types
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "primary" | "secondary";
}
function Button({
label,
onClick,
disabled = false,
variant = "primary",
}: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}Memoize when profiling says so
// ✓ Memoize expensive components
const UserCard = React.memo(({ user }: { user: User }) => (
<div>
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
));
// ✓ Memoize with custom comparison
const Button = React.memo(
({ onClick, children }: ButtonProps) => (
<button onClick={onClick}>{children}</button>
),
(prevProps, nextProps) => {
// Only re-render if onClick changes
return prevProps.onClick === nextProps.onClick;
},
);Takeaways
- JSX is syntax sugar — it compiles to runtime helpers (
jsx/jsxstoday, orcreateElementwith the classic transform). - Capitalization decides intrinsic vs component:
<div>vs<Div>. - Children become
props.children; expressions go in{}, not statements. - Type props explicitly; extend
React.ComponentProps/HTMLAttributeswhen wrapping native elements. - Extract nested JSX early; keep files readable before reaching for memoization.
- Watch the usual traps:
className, objectstyle, fragmentkey,&&with0, and calling handlers during render (onClick={fn()}).
Next in this series: The Fiber Tree — what React builds after JSX becomes elements.