Home / Articles / JSX Is Not HTML: The Real Trade-offs Behind Component Markup

This article is published in English.

JSX Is Not HTML: The Real Trade-offs Behind Component Markup

Understand what you give up when markup becomes JSX, from tooling and parsing to forms, accessibility and portability, and the patterns that win most of it back.

3076 words

Almost every React tutorial reassures newcomers that JSX is "basically HTML inside JavaScript". The resemblance is real, but the reassurance hides a set of costs that show up later in build pipelines, bundle sizes, input latency and accessibility audits. Below, you will see what JSX actually is beneath its appearance, which capabilities change when markup moves from the browser's parser into a JavaScript runtime, and which concrete patterns recover most of what is lost without giving up components.

A familiar look over a different machine

JSX borrows HTML's surface almost entirely. Elements open with <button>, close with </button> and nest the way markup has nested since the 1990s. That familiarity made the move to single-page applications far easier for a whole generation of developers:

// It looks like HTML...
function UserCard({ name, role }) {
  return (
    <div className="card">
      <h3>{name}</h3>
      <p>{role}</p>
    </div>
  );
}

Underneath, though, the two are unrelated technologies. HTML is a declarative markup language that browser engines parse directly with heavily optimized native code. JSX is syntax that a compiler rewrites into nested JavaScript function calls: React.createElement in the classic transform, or helpers like _jsx() in the modern automatic runtime. The <div> in a component is not markup at all; it is an argument list.

Switching gets you a lot: component trees driven by data, automatic synchronization between state and the DOM, and type checking of your templates. Every abstraction has a price, though. Replacing a native browser format with a JavaScript layer means depending on build tooling, spending more memory at runtime, and bypassing several resilience features the platform gives you for free. None of these costs are reasons to avoid JSX, but each is worth knowing about when you design an application.

The tooling tax

Losing the double-click workflow

The first thing that disappears is the web platform's zero-dependency simplicity. A plain HTML page needs nothing beyond an editor and a browser. You can create index.html on an offline machine, double-click it, and the browser renders it from a file:// URL immediately.

No JavaScript engine, whether V8, JavaScriptCore or SpiderMonkey, understands <div className="box"> as code. Before anything reaches the screen, JSX needs a compilation chain:

  • a compiler such as Babel, SWC or esbuild
  • a bundler such as Vite, Webpack, Rollup or Turbopack
  • npm, pnpm or yarn to install dependencies
  • Node.js or Bun to execute those tools
  • a node_modules folder that commonly weighs hundreds of megabytes of presets, parsers, plugins and polyfills

(In-browser Babel builds exist for quick experiments, but they are not something you ship.) The difference in the path from source file to pixels looks like this:

HTML Workflow:
[index.html] ----------> Directly parsed by Browser Engine (Instant)

JSX Workflow:
[Component.jsx]
   └─> AST Parsing
        └─> Transpilation (_jsx() calls)
             └─> Bundling & Minification
                  └─> Network Download
                       └─> JS Parse & Compile
                            └─> Runtime Virtual DOM
                                 └─> DOM Mutation

The maintenance burden

Coupling markup to a compiler creates ongoing costs:

  • Toolchain rot. A project nobody touched for three years frequently refuses to build, because upstream packages, required Node.js versions or bundler APIs have moved on.
  • Source map fragility. Debugging production means mapping minified output back to the original components. When source maps are missing or wrong, stack traces show anonymous runtime calls rather than your code.
  • Build feedback lag. Modern bundlers written in Rust or Go are extremely fast, yet in very large codebases continuous recompilation still adds a delay that simply does not exist when you edit raw markup.

Syntax constraints inherited from JavaScript

Because JSX is parsed as JavaScript, it inherits JavaScript's reserved words and stricter grammar, and loses the forgiving nature of HTML.

Reserved words become renamed props

An HTML attribute is a string attached to a DOM node. A JSX prop is a key in an object passed to a function. Since class and for are reserved words in JavaScript, JSX substitutes other names. The standard HTML form:

<!-- Native, standards-compliant HTML -->
<label for="username">Username</label>
<div class="profile-card" tabindex="0"></div>

becomes the following in JSX. Notice also that tabIndex receives a number expression rather than a string:

// JSX equivalents forced by JavaScript engine constraints
<label htmlFor="username">Username</label>
<div className="profile-card" tabIndex={0}></div>

Case sensitivity and style objects

HTML attribute names are case-insensitive, while JSX props are case-sensitive and mostly camelCased: onClick, strokeWidth, autoComplete, tabIndex. One correction to a common claim: aria-* and data-* attributes are the exception and keep their hyphenated names in JSX, so aria-label is written exactly as in HTML.

Inline styles change more fundamentally. In HTML a style is a plain string that the browser parses:

<!-- HTML: Zero runtime allocation -->
<div style="background-color: red; margin-top: 10px;"></div>

In JSX it is a JavaScript object literal, and a literal written inside the render output is created anew every time the component renders:

// JSX: Allocates a new JavaScript object on every single render pass
<div style={{ backgroundColor: 'red', marginTop: '10px' }} />

For most components this is negligible. In components that re-render very frequently, such as large data grids or pointer-driven canvas overlays, thousands of short-lived style objects add garbage-collection pressure that can surface as pauses. Hoisting static style objects out of the component, or using class names, avoids it.

Strict closing rules

HTML5 deliberately tolerates void elements without closing slashes: <input>, <img>, <br> and <hr> are all valid as written. JSX follows XML rules instead. Forget the self-closing slash or a closing tag and the compiler stops with a syntax error, so the build fails rather than degrading gracefully.

Runtime cost: native parsing versus a virtual DOM

When the browser receives HTML, it tokenizes bytes as they arrive and builds DOM nodes incrementally, using native code tuned over decades and helped by a speculative preload scanner that discovers resources early. When an application renders entirely through JSX, that native path is largely bypassed in favor of JavaScript execution:

Standard HTML Processing:
Network Bytes ──> Tokenizer ──> DOM Tree ──> Render Tree ──> Paint

JSX / Virtual DOM Processing:
Network Bytes ──> JS Parse/Compile ──> JS Execution ──> Component Tree
              ──> VDOM Allocation ──> VDOM Diffing (Reconciliation)
              ──> DOM Patching ──> Render Tree ──> Paint

Memory and garbage collection

To compute updates, React keeps a description of the UI in JavaScript memory, commonly called the virtual DOM. The cycle works roughly like this:

  1. The first render creates a tree of JavaScript objects describing each element, its props and its children.
  2. When state changes, the affected components run again and produce a new description of their part of the tree.
  3. React compares the new description with the previous one (reconciliation) to find the minimal set of changes.
  4. Only those changes are applied to the real DOM.

Note that React re-renders the subtree below the component whose state changed rather than the whole application, but the principle stands: the browser already holds the real DOM in its own internal memory, and the JavaScript heap now holds a parallel representation as well. That extra allocation raises memory use and garbage-collection frequency, which is most noticeable on low-end mobile devices.

The hydration bill

Server-side rendering in frameworks such as Next.js or Remix sends real HTML so the first paint is fast. Before that page responds to input, however, the browser must download the JavaScript for the components on it, execute them, rebuild React's internal tree and attach event handlers to the existing DOM. This hydration step occupies the main thread, and on heavy pages it shows up as a high Total Blocking Time (TBT) and a weak Interaction to Next Paint (INP) score.

Streaming and fault tolerance

HTML was designed around two principles that JSX-centric single-page apps often undermine: incremental streaming and tolerance of errors.

When streaming disappears

A browser starts rendering a document before it has fully downloaded. Suppose the server has delivered the <head> plus 50KB of a 200KB page so far; the browser can already request stylesheets and fonts and paint the header and navigation while the rest is still in transit.

A purely client-rendered JSX app works differently:

  • the browser receives an almost empty shell containing only <div id="root"></div>
  • it downloads the JavaScript bundle
  • it parses and executes that bundle
  • the components run, build the DOM and finally show content

On a slow 3G connection or an underpowered phone, the user sees a blank page for that entire sequence. This is everything the browser has to work with at first:

<!-- What the browser sees initially in a standard JSX SPA -->
<!DOCTYPE html>
<html>
  <head>
    <title>App</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="/static/bundle.8f9b2c.js"></script>
  </body>
</html>

When errors stop being forgiven

The HTML parser is famously tolerant. Feed it broken markup like this:

<div>
  <p>Unclosed paragraph
  <div>Nested incorrectly</b>
</div>

and it does not fail. The parsing algorithm closes and re-nests elements according to well-defined recovery rules and shows the content anyway.

React is far less forgiving at runtime. If rendering throws, for example because an expression like {user.profile.name} reads a property of undefined, React unmounts the entire tree when no error boundary catches the error, leaving a blank screen. React does not ship a ready-made <ErrorBoundary> component; you write one as a class component (or use a small library) and place it deliberately around risky regions.

Forms and events

HTML forms have handled input, validation and submission natively since the early web. Typical JSX patterns often replace those primitives with JavaScript re-implementations.

Controlled versus native inputs

A plain <input> keeps its own state. Typing updates the browser's internal buffer immediately, with no script involved. The idiomatic React pattern instead makes the input controlled, so React state becomes the source of truth:

// Every keystroke triggers a state change, a re-render, and a VDOM diff
function SearchInput() {
  const [value, setValue] = useState("");

  return (
    <input
      type="text"
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

Now each keystroke passes through the event system, sets state, runs the component function again, reconciles, and then pushes the value back into the DOM. That is fine for a small component. When the main thread is busy with data processing or heavy animation, or when the input sits inside a large component tree that re-renders with it, users can see characters appear noticeably after they type them.

Synthetic events

React wraps native browser events in its own synthetic event system, originally to smooth over inconsistencies between older browsers. The abstraction brings some hidden friction:

  • propagation through React's tree can differ from propagation through native DOM listeners, which confuses code mixing both
  • React delegates listeners to a single root node (the document before React 17, the root container since), which can create ordering surprises when integrating vanilla JavaScript libraries that attach their own listeners
  • each native event is wrapped in an additional object

Accessibility and semantic drift

JSX can produce perfectly accessible, semantic HTML. The patterns it encourages, however, tend to erode semantics over time.

Div soup from component wrappers

A component must return a single root node. Fragments (<Fragment> or <>) solve this without extra DOM, but many codebases still wrap children in <div> containers out of habit or for layout. The structure you meant to produce is this:

<!-- What you intended to build -->
<main>
  <article>
    <h1>Article Title</h1>
    <p>Content goes here...</p>
  </article>
</main>

while layered wrapper components frequently render something closer to this:

<!-- What JSX component wrapping often generates in the actual DOM -->
<div class="AppWrapper">
  <div class="LayoutContainer">
    <main>
      <div class="ArticleWrapper">
        <article>
          <div class="HeadingGroup">
            <h1>Article Title</h1>
          </div>
          <div class="ParagraphContainer">
            <p>Content goes here...</p>
          </div>
        </article>
      </div>
    </main>
  </div>
</div>

The extra layers make the DOM heavier, complicate CSS layout and add noise between landmark elements. Generic <div>s without roles are mostly ignored by assistive technology, but deep wrapper chains still make the markup harder to reason about and easier to get wrong, for example when a wrapper accidentally breaks a list or heading structure.

Keyboard behavior you get for free, until you don't

Native interactive elements such as <button>, <a>, <select> and <details> come with behavior that is easy to take for granted:

  • they are focusable in tab order by default
  • Enter and Space activate them automatically
  • they expose the correct roles and states to assistive technology
  • they show focus indicators and are announced properly by screen readers

Because JSX makes attaching a click handler to any element trivial, as in <div onClick={handleClick}>, teams regularly build custom controls from non-semantic elements and forget the keyboard handlers, tabIndex and ARIA roles those native elements supplied automatically. Our overview of hidden React component pitfalls covers more of these traps.

Standards, interoperability and lock-in

HTML is an open standard maintained by the WHATWG, with the W3C historically involved. Pages written in 1997 still render in current browsers. JSX, by contrast, is not a web standard. It has an informal specification and is supported by several libraries, including React, Preact and Solid, but every use depends on a compiler and on the runtime that the compiled calls target. That is a softer form of lock-in than a proprietary format, but lock-in nonetheless.

Custom elements and the platform's own component model

Browsers already ship a component model: Custom Elements plus Shadow DOM. Plain HTML uses them directly:

<user-avatar src="avatar.jpg" size="large"></user-avatar>

React historically handled custom elements poorly, for two reasons:

  • it passed all props to unknown lowercase tags as string attributes, so objects and arrays could not be handed over as element properties
  • custom events such as one dispatched with new CustomEvent('user-select') did not map to a prop like onUserSelect, forcing wrapper components or manual listeners through refs

React 19 addressed much of this by setting properties on custom elements when the element defines them and by supporting custom event handlers, so check which React version you target before assuming these limitations still apply.

Portability of markup

A design system written in standard HTML and CSS can be consumed from anywhere: WordPress, Django, Ruby on Rails, Go templates, Vue, Angular, Svelte or plain static pages. A design system written as JSX components is tied to the JavaScript ecosystem. Using it from a non-JavaScript backend requires a Node.js rendering service or a second implementation of every component.

HTML and JSX side by side

Summarizing the trade-offs:

  • Execution: HTML is parsed natively by the browser; JSX compiles to JavaScript function calls that run at runtime.
  • Tooling: HTML needs an editor and a browser; JSX needs a compiler, bundler, package manager and build runtime.
  • Syntax: HTML is case-insensitive and tolerant; JSX is case-sensitive, uses renamed props and requires XML-style closing.
  • Rendering: HTML streams and paints incrementally; client-rendered JSX waits for the bundle to download and run.
  • Errors: HTML recovers from malformed markup; an uncaught render error unmounts the React tree.
  • Forms: native inputs hold their own state; controlled inputs re-render on every keystroke.
  • Portability: HTML works with any backend or framework; JSX components require the JavaScript ecosystem.
  • What JSX adds: composable components, declarative data-driven updates and type-checked templates.

Reclaiming what you lost

None of this argues for abandoning component-based development. It argues for being deliberate about where the abstraction is worth its cost. The ecosystem has moved in exactly this direction, with patterns that restore native HTML's speed and resilience while keeping a declarative authoring model.

Choose server-first rendering

  • React Server Components render on the server and send no component JavaScript to the client for parts that are not interactive.
  • Astro uses an islands architecture: pages are static HTML by default, and only isolated interactive widgets are hydrated.
  • Qwik replaces hydration with resumability, serializing application state into the HTML so code runs only when the user actually interacts.

Let the browser own form state

Instead of mirroring every keystroke into state, let the native <form> hold the values and read them once on submit with FormData:

// Clean, native, performant HTML-first form submission
function LoginForm() {
  function handleSubmit(event) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const email = data.get("email");
    // Send payload...
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" name="email" required />
      <button type="submit">Sign In</button>
    </form>
  );
}

The input updates at native speed, the required attribute gives you built-in validation, and the component renders once rather than on every keystroke. Recent React versions build on the same idea with form actions, which accept FormData directly.

Keep semantics strict

Treat JSX as a way to produce semantic HTML, not as license to stack containers:

  • replace wrapper <div>s with fragments (<></>) wherever the wrapper has no styling purpose
  • use native interactive elements such as <button>, <dialog>, <details> and <summary> instead of hand-built widgets
  • add eslint-plugin-jsx-a11y to continuous integration so missing labels, roles and keyboard handlers fail the build

Wrapping up

JSX changed front-end development by showing that interfaces are best described as predictable functions of data, and it solved real problems around keeping large, dynamic UIs in sync with state. It is still a JavaScript abstraction, not a newer version of HTML. Choosing it means trading native streaming, build-free authoring, error tolerance and long-term standards stability for composition and reactive ergonomics. That is often a good trade. The engineering skill lies in knowing exactly what you traded and in reaching for server rendering, native forms and semantic elements wherever you can get those capabilities back for free.