Concept
The beginner framing: JSX lets you write HTML-like markup directly inside JavaScript, <h1>Hello</h1> right in the middle of a function, which a build step (Babel, SWC, or the Next.js compiler) transforms into regular JavaScript before it ever runs in a browser.
The precise mental model: JSX is syntax sugar for function calls that build element description objects (see Components for what those descriptions are). Every JSX tag compiles to a call that produces a plain object, nothing about JSX is special-cased by the JavaScript engine itself; by the time your code actually runs, there is no JSX left, only ordinary function calls.
const element = <h1 className="title">Hello</h1>;compiles (via the modern "automatic" JSX runtime that React 17+ and this app use) to roughly:
import { jsx as _jsx } from "react/jsx-runtime";
const element = _jsx("h1", { className: "title", children: "Hello" });Older code (and React versions before 17) compiled JSX to React.createElement("h1", { className: "title" }, "Hello") instead, functionally equivalent, just requiring React to be imported in every file that used JSX. The modern transform imports jsx/jsxs automatically per-file, which is why you no longer need import React from "react" just to use JSX.
JSX is an expression, not a set of statements
Because a JSX tag compiles down to a single function call, and a function call is an expression (it produces a value), JSX can appear anywhere an expression is valid, assigned to a variable, returned, passed as an argument, embedded inside {} in other JSX. But this also means JSX cannot contain statements like if, for, or switch directly, because statements don't produce a value the way expressions do.
// ❌ SyntaxError, `if` is a statement, not an expression
function Greeting({ isLoggedIn }) {
return (
<div>
{if (isLoggedIn) { <span>Welcome back</span> }}
</div>
);
}
// ✅ Use an expression instead: ternary, &&, or a variable computed beforehand
function Greeting({ isLoggedIn }) {
return <div>{isLoggedIn ? <span>Welcome back</span> : <span>Please log in</span>}</div>;
}Conditional rendering patterns
{condition ? <A /> : <B />} // if / else, both branches are expressions
{condition && <A />} // render A, or nothing, if condition is falsy
{items.length > 0 ? <List items={items} /> : <EmptyState />}A common trap with &&: if condition is 0 (a valid falsy number, not a boolean), {0 && <A />} renders the literal text "0" on the page, since 0 is what the expression evaluates to and JSX renders any non-boolean, non-null/undefined value as text. Guard with an explicit boolean check (items.length > 0 && ...) rather than relying on truthiness of a number.
Keys: telling React which item is which across renders
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}key isn't a regular prop your component receives, React intercepts it to identify which array item is which across re-renders, so it can correctly match up old and new elements instead of guessing by position. This becomes critical (and has real failure modes when done wrong) once lists can reorder, covered in full in Reconciliation.
Try It
Predict what this renders, paying attention to the &&.
function Cart({ itemCount }) {
return (
<div>
{itemCount && <span>{itemCount} items in cart</span>}
</div>
);
}
<Cart itemCount={0} />Solution
It renders the literal text "0" on the page, not nothing, as most people expect. itemCount && <span>...</span> evaluates 0 && <span>...</span>, and since 0 is falsy, the && short-circuits and evaluates to 0 itself (not false, not undefined), and JSX renders any number, including 0, as visible text.
{itemCount > 0 && <span>{itemCount} items in cart</span>}Comparing to produce an actual boolean (itemCount > 0) fixes it, renders nothing, while renders as the string .
Implement It Yourself
Write your own tiny jsx-equivalent function and use it without any JSX syntax at all, to see precisely what the compiler is doing on your behalf:
function jsx(type, props, ...children) {
return { type, props: { ...props, children: children.flat() } };
}
// This hand-written call is EXACTLY what `<ul><li>A</li><li>B</li></ul>` compiles to.
const list = jsx("ul", null,
jsx("li", null, "A"),
jsx("li", null, "B")
);
console.log(JSON.stringify(list, null, 2));
// {
// "type": "ul",
// "props": { "children": [
Every JSX file you write is shorthand for building exactly this kind of nested object graph, the jsx/jsxs functions React actually ships do a bit more bookkeeping (assigning an internal $$typeof marker used to prevent certain injection attacks, tracking key separately), but the shape and idea are identical.
Under the Hood
JSX's "expression-only" restriction is a direct consequence of it compiling to function calls, this is the same expression-vs-statement distinction that governs plain JavaScript (an arrow function's concise body, const x = condition ? a : b, array .map() callbacks, all expression-based for the same underlying reason). There's no new rule to learn here specific to React; JSX is bound by the exact same "statements don't produce values" constraint that already exists in the language.
Common Mistakes
1. Trying to use if/for directly inside JSX
Covered above, replace with ternaries, &&, .map(), or compute the value in a variable before the return statement, then reference that variable inside the JSX.
function Status({ code }) {
let message; // ordinary JS statement, OUTSIDE the returned JSX
if (code === 200) message = "OK";
else message = "Error";
return <p>{message}</p>; // only the EXPRESSION (the variable) goes inside JSX
}2. Rendering 0, NaN, or an empty string unintentionally via &&
Covered above, always coerce to an actual boolean (count > 0 && ..., !!value && ...) when the left side of && might be a falsy non-boolean.
3. Using array index as key for a list that can reorder
{items.map((item, index) => (
<li key={index}>{item.name}</li> // ❌ breaks badly if items are added/removed/reordered
))}Index-as-key works fine for a static, never-reordered list, but silently causes wrong state/DOM to "stick" to the wrong row when the list changes order, a genuinely common, hard-to-spot bug covered in depth in Reconciliation. Prefer a stable, unique id from your actual data.
Best Practices
- Compute non-trivial logic in a variable before
return, and keep the JSX itself close to declarative markup, areturnstatement packed with nested ternaries is a sign to extract a helper variable or a separate component. - Always use
!!or an explicit comparison on the left side of&&when the value could be a falsy number or empty string. - Use a stable, unique
keyfrom your data (an id), never the array index, for any list that can reorder, filter, or have items added/removed. - Prefer fragments (
<>...</>) over a wrapping<div>purely to satisfy "must return one root" when you don't actually want extra markup in the DOM.
Performance Tips
- JSX compilation happens entirely at build time, there is zero runtime cost difference between writing JSX and hand-writing the equivalent
jsx()/createElement()calls yourself. Don't avoid JSX for "performance"; it compiles away completely. - The
keyprop directly affects reconciliation performance and correctness: a good key lets React do a cheap, targeted update; a missing or unstable key can force React into unnecessarily destroying and recreating DOM nodes on every render (see Reconciliation for the full mechanism).
