What Is JSX?
JSX looks like HTML that somebody pasted into the middle of a JavaScript file, and that resemblance causes most of the confusion around it. What is JSX, precisely? It is a syntax extension — markup that a compiler rewrites into ordinary function calls before any of it reaches a browser. There is no HTML parser involved, no template engine, and nothing being stitched together from strings at runtime.
That single fact explains the rest of this page: why .tsx files exist, why class is spelled
className, why TypeScript can type-check your markup at all, and why JSX works just as happily in
Preact and Solid as it does in React.
What is JSX, exactly
Write this:
[object Object]The compiler does not keep that markup. It rewrites it into a function call:
import { jsx } from 'react/jsx-runtime'
const greeting = jsx('p', { className: 'greeting', children: 'Hello, Ada' })That is the entire transform. The tag name becomes the first argument, the attributes become a
props object, and the children become props.children. Nesting markup just nests the calls.
Because the output is a function call, everything that is true about function calls is true about JSX. It is an expression, which means it can be assigned to a variable, returned from a function, stored in an array, or passed as an argument:
const rows = users.map((user) => <li key={user.id}>{user.name}</li>)
const panel = condition ? <Chart data={rows} /> : <EmptyState />It also means JSX does not render anything by itself. The call produces a plain object describing
what should exist — a description, not a DOM node. Something else, usually a renderer like
react-dom, takes that object and decides what to do with it. Evaluating JSX in isolation is
roughly as eventful as building an object literal.
JSX is not HTML
The markup is close enough to HTML to be readable and different enough to catch you out. Every difference comes from the same place: those attributes end up as keys in a JavaScript object, so they have to be legal JavaScript.
| HTML | JSX | Why |
|---|---|---|
class="box" | className="box" | class is a reserved word |
for="email" | htmlFor="email" | for is a reserved word |
onclick="" | onClick={handler} | props are camelCase, and the value is a function |
tabindex="0" | tabIndex={0} | camelCase again, and a real number |
<br> | <br /> | every element must be explicitly closed |
Two more rules are worth knowing before you write your first component. Curly braces embed a
JavaScript expression, not a string template — {user.name} is an expression slot, and {/* … */}
is how you write a comment inside markup. And a JSX expression has exactly one root element, which
is why fragments exist:
function Summary({ title, body }) {
return (
<>
<h2>{title}</h2>
<p>{body}</p>
</>
)
}The <>…</> fragment groups the two elements without adding a wrapper element to the output.
JSX is not React-only
JSX is a syntax, and syntax does not belong to a library. The compiler is told which function to hand the markup to, and anything that exports a matching function can receive it. Preact, Solid, Hono and Vue all consume JSX; React is simply the most common thing on the other end of it.
In TypeScript, the target is one compiler option:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}With jsxImportSource set to preact, the generated import becomes preact/jsx-runtime instead of
react/jsx-runtime. Nothing in your markup changes. The default for jsxImportSource is react,
which is why most projects never set it.
Which jsx setting to use in tsconfig
The jsx option has a handful of values, and three of them matter in practice:
| Value | What the compiler emits | Use it when |
|---|---|---|
preserve | the JSX, untouched, in a .jsx file | a bundler (Next.js, Vite, esbuild) does the transform |
react-jsx | calls into jsx() from the automatic runtime | tsc emits your JS and you are on React 17+ |
react | calls into React.createElement() | you are stuck on an older React setup |
preserve is the most common setting in modern apps, and it surprises people: TypeScript type-checks
the JSX and then hands it off untransformed, because the bundler is going to do a better job of the
emit. The type checking is the part you actually wanted from tsc anyway.
react-jsx is the one that injects the react/jsx-runtime import automatically, which is why
modern React code no longer needs import React from 'react' at the top of every file. Both of
these settings live in your tsconfig.json, and the runtime import they
generate is an ordinary ES module import like any other.
JSX in TypeScript needs a .tsx file
TypeScript will not parse JSX in a .ts file, and the reason is a grammar conflict rather than a
policy: <Something> already means a type assertion in TypeScript, so the compiler picks its
grammar from the file extension. Markup goes in a .tsx file, everything else
can stay in .ts.
Typing what goes into JSX
A component is a function, so typing a component is just typing a function — the props are its single parameter, and an interface is the usual way to describe that parameter:
interface AvatarProps {
src: string
alt: string
size?: number
onSelect?: (id: string) => void
}function Avatar({ src, alt, size = 40 }: AvatarProps) {
return <img src={src} alt={alt} width={size} height={size} />
}Once the props have a type, the markup is checked against it. Misspell an attribute, pass a string where a number belongs, or forget a required prop, and the error arrives at the call site:
<Avatar src="/ada.png" /> // ❌ Property 'alt' is missing
<Avatar src="/ada.png" alt="Ada" size="40" /> // ❌ string is not assignable to number
<Avatar src="/ada.png" alt="Ada" size={40} /> // ✅This is where JSX stops being a cosmetic choice. Props are a function's parameter list wearing angle brackets, so the compiler can enforce them the same way it enforces any other call. A union type is enough to make illegal combinations unrepresentable:
type BannerProps =
| { variant: 'error'; retry: () => void }
| { variant: 'info'; retry?: never }Now <Banner variant="error" /> is a type error, because the error variant requires a retry
handler, while <Banner variant="info" /> is fine.
Children are just another prop
Nothing special happens to what you nest inside a tag. It is collected into props.children, which
means a component that wraps content declares it like any other field:
type Renderable = string | number | boolean | null | undefined
interface CardProps {
title: string
children: Renderable | Renderable[]
}In a React codebase you would write children: React.ReactNode instead and let the library's type
cover elements, fragments and arrays for you. The point of spelling it out here is that children
is not a keyword — it is a prop name the compiler fills in from the markup, and you can pass it
explicitly as an attribute if you ever want to.
Where the types for <div> come from
Lowercase tags are treated differently from capitalised ones, and that rule is part of the syntax
itself. A lowercase tag is an intrinsic element — the compiler looks it up by name in the
JSX.IntrinsicElements interface, which @types/react declares and fills with every DOM element
and its attributes. A capitalised tag refers to a value in scope, so the compiler resolves it like
any other identifier and reads the props off its signature.
That is why <div classname="box"> errors on the attribute while <Avatar> errors on the import.
It is also why your own components must start with a capital letter: <avatar /> sends the
compiler looking for an intrinsic element named avatar, finds nothing, and reports that the
property does not exist on JSX.IntrinsicElements.
When a component's type does not line up with what the renderer expects, you get
TS2786 — usually two copies of @types/react in the dependency tree rather than
anything wrong with your markup.
Three things that trip people up
&& leaks falsy values. Because JSX is an expression, {count && <Badge />} evaluates to
count when count is 0 — and React renders a literal 0 on the page. Use a comparison instead
of relying on truthiness:
[object Object]Quotes and braces are not interchangeable. width="40" passes the string "40", while
width={40} passes the number. For a prop typed as number the first one is a type error, which is
the compiler doing you a favour — in plain JSX it would have silently shipped a string.
Spreading props hides required ones. <Avatar {...rest} /> type-checks against whatever rest
is declared as, so a loosely typed rest quietly disables the checks you set up in the first place.
Spread is fine when the source object has a precise type and nothing you care about otherwise.
Summary
JSX is a syntax extension that compiles to function calls. The tag becomes an argument, the
attributes become a props object, and the result is a plain value that a renderer interprets later.
Everything that feels arbitrary about it follows from that: className because props are
JavaScript, .tsx because <T> was already taken, jsxImportSource because the receiving function
is configurable, and full type checking because a component is a function and props are its
parameter.
Become a TypeScript Pro
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges