Skip to main content
ASoc
Tutorial

React JSX: The Rules That Only Break the Production Build

JSX compiles to function calls, not HTML. Where the two diverge, why a lowercase SVG tag survives dev and fails next build, and the key rule behind phantom state bugs.

The ASoc Team8 min read

JSX looks like HTML, which is the whole point and also the whole problem. The places it diverges are invisible in the editor, survive the dev server, and fail the production build. This codebase has 139 .tsx files and hit exactly that class of bug; here is what JSX actually compiles to and which of its rules only bite at build time.

The short answer

JSX is a syntax extension that lets you write markup inside JavaScript. A bundler compiles every tag into a function call — jsx("div", { className: "card" }) under React 17+ — so JSX is never sent to the browser. It is not HTML: attributes use camelCase, class becomes className, and element names are case-sensitive.

What a JSX component compiles to

A JSX component is an ordinary function that returns JSX. There is no class, no registration, no template file:

export default function HeroBadge({ label }: { label: string }) {
  return (
    <span className="rounded-full bg-primary/10 px-3 py-1 text-sm text-primary">
      {label}
    </span>
  );
}

The compiler rewrites that return value into a call that produces a plain object — the element description React later reconciles against the DOM. Two consequences follow immediately, and both explain rules that otherwise look arbitrary:

A component must return one expression. A function call returns one value, so two sibling tags at the top level are a syntax error. Wrapping them in a fragment — <>…</> — gives you one element that renders no wrapper node.

Capitalisation is semantic. A lowercase tag compiles to the string "div"; a capitalised tag compiles to the identifier HeroBadge. Writing <hero-badge /> does not call your component, it asks the DOM for an unknown element. This is why every component name in React is capitalised — it is not a convention, it is how the compiler tells markup from code.

You no longer import React

With "jsx": "react-jsx" in tsconfig.json — the default in every current scaffold — the compiler injects the JSX runtime itself. Across 220 source files here, import React from "react" appears 0 times. If a tutorial opens each component with that line, it is written against the pre-React-17 classic runtime.

Where JSX stops being HTML

HTMLJSXWhy
class="card"className="card"class is a reserved word in JavaScript
for="email"htmlFor="email"Same reason
onclick="…"onClick={fn}Handlers are function references, not strings
tabindex, colspantabIndex, colSpanAttributes become object properties, in camelCase
<input> unclosed<input />JSX is XML-strict; every element closes
style="color: red"style={{ color: "red" }}An object, not a CSS string
<!-- comment -->{/* comment */}Comments are JS expressions
&nbsp;{" "} or the literal characterEntities are not parsed inside expressions

Braces are the seam between the two languages. Everything inside { } is a JavaScript expression — so a ternary works and an if statement does not, and {items.map(…)} is the whole story of rendering a list. Values that render as nothing are worth memorising: null, undefined, false and true all produce no output, but 0 renders as the character 0. That is the mechanism behind the most common React display bug — {items.length && <List />} prints a bare 0 on an empty array, where {items.length > 0 && <List />} prints nothing.

The rule that only fails at build time

The divergence that cost this project real time is SVG. Icons here are embedded directly in data files — 12 of the 15 files in src/data are .tsx rather than .ts purely because their items carry markup like this:

export const featureCards: FeatureItem[] = [
  {
    icon: (
      <svg className="h-12 w-12" fill="none" viewBox="0 0 50 50">
        <path clipRule="evenodd" fillRule="evenodd" fill="currentColor" d="…" />
      </svg>
    ),
    title: "Powered by Tailwind CSS",
    description: "…",
  },
];

Every SVG attribute there is camelCased, and so are SVG element names: <clipPath>, <linearGradient>, <feGaussianBlur>. Paste an SVG straight out of a design tool and you get the lowercase HTML spelling, which renders in the dev server and then fails the build. Run against this repo today:

src/probe.tsx(4,7): error TS2339: Property 'clippath' does not exist on
  type 'JSX.IntrinsicElements'.

That is npx tsc --noEmit on a deliberately broken <clippath>. The element is not in React's intrinsic-element table, so there is nothing to type-check it against — and because the dev server does not run the type-checker, nothing surfaces until next build. If you take one habit from this article, take this one: type-check before you push. npx tsc --noEmit is seconds; a red CI run is not.

JSX is a compile target, not just something you write

Because JSX is just function calls, anything that can produce those calls can produce JSX — which is how this site's articles are rendered. Our blog posts are MDX compiled at build time, and src/mdx-components.tsx is the map that decides what element each Markdown construct becomes:

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    h2: ({ children, ...props }) => (
      <h2 className="mt-12 mb-4 text-2xl font-bold text-title-color dark:text-white" {...props}>
        {children}
      </h2>
    ),
    ...components,
  };
}

A ## heading in the Markdown source becomes that <h2> call. The spread — {...props} — is JSX's other genuinely important operator: it forwards every remaining attribute, which is how the heading keeps the id that rehype-slug generated without the component knowing the prop exists. Spread last to let callers override; spread first to set defaults they can replace.

Rendering a list, and the prop that is not a prop

Almost every section on this site is one organism mapping over one typed data array. The pattern is three lines of JSX and one rule:

<div className="grid gap-6 md:grid-cols-3">
  {featureCards.map((item) => (
    <FeatureCard key={item.title} {...item} />
  ))}
</div>

key is the one attribute React reads rather than passes down — FeatureCard never receives it. It is how React matches elements across renders, and getting it wrong produces bugs that look like state corruption: an input in the third card keeps its value when the third card becomes a different product. Key on a stable identifier from the data (here a title, elsewhere a catalog slug), never on the array index of a list that can reorder or have items removed.

The {...item} spread beside it is the same operator as in the MDX map above, doing the mundane job: every field of the typed item becomes a prop, and TypeScript checks the shape at the call site.

Mistakes and how they show up

SymptomCauseFix
Adjacent JSX elements must be wrapped in an enclosing tagTwo top-level siblings returnedWrap in a fragment <>…</>
A stray 0 appears in the UI{count && <X />} with count === 0{count > 0 && <X />}
Property 'clippath' does not exist on type 'JSX.IntrinsicElements'HTML-cased SVG element pasted from a design toolJSX casing: clipPath, linearGradient, feGaussianBlur
Unexpected token '<' in a plain .js/.ts fileNo JSX transform for that extensionRename to .jsx/.tsx; React does not transform JSX itself
Styles silently ignoredclass= instead of className=Rename; React will also warn in the console
Objects are not valid as a React childAn object rendered directly in bracesRender a field, or JSON.stringify it
Every list item re-renders or loses stateNo key, or key={index} on a reorderable listKey on a stable id from the data
Component renders as literal markupLowercase tag nameCapitalise it — lowercase compiles to a DOM string

Frequently asked questions

Is JSX required to use React? No. React.createElement("div", null, "hi") is valid React and JSX compiles to roughly that. Nearly every production codebase uses JSX anyway, because nested createElement calls are unreadable at depth.

Is JSX HTML? No — it is JavaScript syntax that resembles HTML. It is stricter (every tag closes), uses camelCased attribute names, and every value in braces is an expression. The table above lists the divergences that actually come up.

What is the difference between JSX and a JSX component? JSX is the syntax; a component is a capitalised function that returns it. <HeroBadge label="New" /> is JSX that invokes the HeroBadge component with { label: "New" } as props.

Can I put an if statement inside JSX? Not inside braces — those take expressions only. Use a ternary, &&, or move the branch above the return and assign to a variable. That last option is usually the most readable once there are more than two cases.

Do I need .jsx or can I use .js? Bundlers vary, but the useful rule is to match the extension to the contents: .jsx/.tsx when the file has markup, .js/.ts when it does not. In TypeScript this is not optional — the JSX parser changes how generic arrow functions are read.

Where to take this next

JSX is the surface; the two things underneath it that change how you write it are types and the server/client split. React with TypeScript covers the type constructs that check your JSX, React Server Components vs. Client Components explains which of your JSX ever reaches a browser, and styling React components picks up where the className row of the table above leaves off.

Templates in this post

ASoc Ledger, ASoc Lens and ASoc Magnet ship with their icon sets already converted to JSX casing, components typed, and a build that type-checks clean — none of the failures in the table above waiting for your first deploy.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial10 min read

React Landing Pages: Half the HTML Isn't the Page

This storefront's home page prerenders to 436 KB, and 52.1% of that is a serialized copy of the render, not the page. What actually reaches a crawler before any JavaScript runs.

Read more
Tutorial7 min read

React Lazy Loading: This Codebase Uses Zero React.lazy() Calls

This codebase has zero React.lazy() calls. What it actually lazy-loads — an explicit per-post import map and a conditional SDK import — and why that distinction matters.

Read more
Tutorial9 min read

React Loading Spinner: 3 Instances, and 3 More Places That Skip It

Three components spin a Loader2 icon; three more swap button text instead and skip it entirely. The rule that decides between them, audited across this codebase's seven loading states.

Read more