JSX
By the end of this lesson
Write markup in JavaScript and understand what it compiles to.
JSX is a syntax extension for JavaScript. It lets you write markup directly in a .jsx or .tsx file, and a build step converts it into ordinary function calls before the browser sees any of it.
No browser understands JSX. There is no JSX at runtime. That one fact explains almost all of its behaviour: the rules you have to follow are JavaScript's rules, not HTML's, because what you are writing is JavaScript wearing a convenient shape.
interface EmployeeCardProps {
name: string;
role: string;
}
export function EmployeeCard({ name, role }: EmployeeCardProps) {
return (
<article className="rounded-xl border p-4">
<h3 className="font-semibold">{name}</h3>
<p className="text-sm text-slate-600">{role}</p>
</article>
);
}- The markup is the return value. This function returns a description of an interface the same way another function returns a number.
- Braces switch from markup back into JavaScript. {name} inserts the current value of the name variable.
- The CSS classes go on an attribute called className rather than class. The reason is in the table further down, and it is not arbitrary.
- The outer brackets around the returned markup are only there so the markup can start on the next line. JavaScript would otherwise treat the line break after return as the end of the statement.
jsx("article", {
className: "rounded-xl border p-4",
children: [
jsx("h3", { className: "font-semibold", children: name }),
jsx("p", { className: "text-sm text-slate-600", children: role }),
],
});- Every element becomes one function call. The tag name is the first argument, the attributes become keys on a plain object, and anything nested inside becomes the children key.
- The jsx function comes from React's JSX runtime, which your build tool imports for you. There is a second variant used for elements with several children, but the shape is identical.
- The call returns a plain JavaScript object describing the element. It does not touch the page. React compares those objects between renders and works out the smallest set of changes to make.
- Older React code called React.createElement and needed React imported in every file that contained markup. Current setups do not, which is why you will see .tsx files with no React import.
JSX looks like HTML and differs in a handful of places. Every difference follows from attributes becoming keys on a JavaScript object:
- className, not class
- class is a reserved word in JavaScript, so it cannot be a plain property name in this position. The same applies to the for attribute on a label, which becomes htmlFor.
- Event handlers are camel case, and take functions
- onClick, onChange, onSubmit. The value is a function reference, not a string of code to evaluate. onClick={handleReset} passes the function; onClick={handleReset()} calls it during render, which is a common and confusing bug.
- aria- and data- attributes keep their hyphens
- aria-label, aria-describedby, data-testid are written exactly as in HTML. They are the deliberate exception, because they pass straight through to the DOM.
- Every element closes
- <input />, <img />, <br />. The compiler is parsing a tree and needs to know where each element ends, so a tag left open is a build error rather than something the browser guesses at.
- style takes an object
- style={{ marginTop: 8 }} — an object with camel-cased property names, not a CSS string. The doubled braces are braces for JavaScript plus braces for the object. Reach for a class instead unless the value is genuinely computed.
- One root element per return
- A function returns one value, so your markup needs one outermost element. When you do not want an extra wrapper in the output, use a fragment: <>...</>. It groups children and renders no element of its own.
interface Employee {
id: string;
name: string;
role: string;
isOnLeave: boolean;
}
export function EmployeeSummary({ employee }: { employee: Employee }) {
const initials = employee.name
.split(" ")
.map((part) => part[0])
.join("");
return (
<article className="flex items-start gap-3">
<span className="rounded-full bg-slate-200 px-2 py-1" aria-hidden="true">
{initials}
</span>
<div>
<h3 className="font-semibold">{employee.name}</h3>
<p className="text-sm text-slate-600">{employee.role}</p>
{employee.isOnLeave ? (
<p className="text-sm text-amber-700">On leave</p>
) : null}
</div>
</article>
);
}- The initials are worked out above the return, where the code is readable. Calculation squeezed inside braces in the middle of markup is where components start becoming hard to follow.
- aria-hidden="true" hides the initials from screen readers on purpose. They are a visual shorthand for a name that is already announced by the heading directly below, so without it a screen reader would read the name twice.
- The ternary renders the paragraph when the employee is on leave and nothing otherwise. null means "render nothing here", and so do undefined, false and true.
- You will also see condition && element, which is shorter and reads well. It has one sharp edge: if the left side is a number, 0 is falsy but still renders, so a count of 0 puts a literal 0 on the page. Compare explicitly — count > 0 && ... — or use a ternary.
Summary
- JSX is JavaScript: markup compiles to function calls that return objects describing elements
- Markup is an expression, so JavaScript supplies the loops and conditionals instead of special directives
- The differences from HTML — className, htmlFor, camel-cased events, closed tags, a single root — all follow from that
- Braces take an expression, never a statement; compute values above the return
- Guard numeric conditions explicitly, because 0 && element renders a stray zero
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Write a DepartmentBadge component that takes a department name and a headcount. It should always show the name, and show the headcount only when it is above zero.
Then render two of them — one with a headcount of 12, one with a headcount of 0 — and check what appears on screen.
Show solution
The condition is headcount > 0 rather than plain headcount. That is the whole point of the exercise. With && on a bare number, a headcount of 0 is falsy, so React skips the element — but it renders the 0 itself, and you get a stray zero on the page with no obvious source.
Comparing explicitly produces a real boolean, and false renders nothing. A ternary returning null is equally correct; choose one and use it consistently in a codebase.
Note that the count is wrapped in its own element rather than being concatenated into the name. Keeping them separate lets you style and label them independently later.
interface DepartmentBadgeProps {
name: string;
headcount: number;
}
export function DepartmentBadge({ name, headcount }: DepartmentBadgeProps) {
return (
<span className="inline-flex items-center gap-2 rounded-full border px-3 py-1">
<span>{name}</span>
{headcount > 0 && (
<span className="text-xs text-slate-600">{headcount} people</span>
)}
</span>
);
}Think about it
Think about it
JSX has no loop syntax and no if syntax, while most template languages have both. Why does JSX get away without them?
Show solution
Because markup compiles to function calls, so a piece of markup is an ordinary JavaScript value. Values can be returned, stored, passed around and collected in arrays — which covers everything a loop directive or a conditional directive would give you.
A template language usually operates on strings and is evaluated separately from the surrounding code, so it has to reinvent control flow inside itself. JSX never leaves JavaScript, so it inherits JavaScript's control flow instead.
The practical consequence: when you are stuck on how to express something in JSX, the question is almost never "what is the JSX syntax for this". It is "what JavaScript expression produces the value I want".
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.