Elements HTML vs JSX

Markdown

JSX is a syntax extension to JavaScript for writing UI markup inside JavaScript and TypeScript files. Its specification, published by Meta alongside React, calls it "an XML-like syntax extension to ECMAScript without any defined semantics," and says it is not meant to be implemented by browsers or added to the language. A compiler such as Babel, TypeScript or esbuild turns each tag into a function call: React.createElement(...) in the classic transform, or _jsx(...) imported from react/jsx-runtime in the current one (the JSX transform). TSX is the same syntax in TypeScript, in .tsx files with the jsx compiler option turned on (TypeScript handbook). React is the main user, and Preact, Solid and others compile JSX for their own runtimes. JSX fills the role of a template language: it is what pages and components are written in.

Elements has a template language of its own. Elements is an integrated app environment, built for people and their agents: a project server that runs while you work, with a package installer, a test runner, a bundled Postgres and deploy in the same system, and a default framework that just works. Pages and components in that framework are written in Elements HTML, a language extension to HTML plus a runtime. An .ehtml file is standard HTML with TypeScript expressions in {...}, typed templates declared with template constructors, and a few e: attributes: e:if, e:for and e:switch. The Elements compiler type checks each template with the rest of the app and emits JavaScript, for the server that renders the page and for the browser that runs it, and the Elements runtime ships to the browser with it.

JSX is JavaScript dressed as HTML, so HTML has to be translated before it goes in. In React, class becomes className, for becomes htmlFor and styles become objects, while conditions and loops become ternaries and .map() calls. JSX also has no meaning of its own. State, input binding, server rendering and updates come from the library that compiles it and the framework around that, so one syntax behaves differently from one project to the next, and every seam between those parts is a place for a person or an agent to go wrong. Elements HTML runs the other way: it is HTML that runs TypeScript. Markup copied from a mockup or from MDN works as written, inputs bind both ways, and one compiler, one runtime and one build cover every template. Each page arrives from the server as complete HTML, and the same page is fully reactive in the browser, with no compromise.

At a Glance

Elements HTML JSX
What it is A language extension to HTML plus a runtime, part of the Elements app framework A syntax extension to JavaScript "without any defined semantics" (spec)
Compiles to JavaScript for the server render and the browser, from the Elements compiler Function calls such as React.createElement or _jsx, from Babel, TypeScript or esbuild
Attributes As in HTML: class, for, style, tabindex, stroke-width In React, DOM property names: className, htmlFor, tabIndex, strokeWidth
Plain HTML Pastes in as written Converted first; React's docs recommend a converter
Conditions e:if, e:elseif, e:else, e:switch Ternaries and &&
Loops e:for, keyed by id by default .map(), with a key on every element
Form inputs Two-way bound by default In React, a value and an onChange handler per input
Typed components Template constructors on the opening tag A props type or interface on a function
Checking One build with tests and migrations, and the same checker in the editor tsc or your editor; Vite's dev server does not type check
Rendering On the server for every page, then reactive in the browser Set by the library and framework: a blank page until JavaScript runs, or server HTML that must be hydrated

Elements HTML Is HTML

React's own guide to JSX lists the rules that make it stricter than HTML (writing markup with JSX). Every tag has to be closed, so <img> becomes <img />. A component returns a single root element. Most attributes are camelCase, and "since class is a reserved word, in React you write className instead." The label's for attribute is htmlFor, because "React uses the standard DOM property names (htmlFor) instead of HTML attribute names," and style takes an object with camelCase property names (common components). The guide's advice for existing markup is to run it through a converter: "Converting all these attributes in existing markup can be tedious!"

Elements HTML has nothing to convert. A tag is an HTML tag, an attribute is an HTML attribute, and void elements such as <input> and <br> need no slash. This form, pasted from a mockup, compiles and renders as written:

app/pages/signup/template.ehtmlElements
<html> <!-- copied from a mockup, unchanged --> <form class="signup" data-step="1" aria-label="Sign up"> <label for="name">Name</label> <input id="name" name="name" autocomplete="name" tabindex="1" required> <br> <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"> <path d="M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> </svg> <p style="color: gray; font-size: 0.875rem">We never share your email.</p> <img src="/logo.png" alt="Logo"> </form> </html>

Dynamic values go in braces anywhere HTML accepts a value. class also takes an array that drops falsy entries, and style also takes an object whose keys are CSS property names as CSS writes them (attributes). Braces always mark TypeScript, and e:literal keeps an element's braces as text, for code samples. A page is a template named <html>, so attributes on it land on the document element.

The Same Template in JSX and Elements HTML

A short list with a labeled input shows the difference line for line. In React, the component holds the input's text in state, wires an onChange handler to keep it there, renames two attributes, and writes the condition and the loop as JavaScript expressions:

app/components/Invites.tsxReact
import { useState } from "react"; import type { Invite } from "../types"; export function Invites({ invites }: { invites: Invite[] }) { const [email, setEmail] = useState(""); return ( <div className="invites"> <label htmlFor="email">Email</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> {invites.length > 0 ? ( <ul> {invites.map((invite) => ( <li key={invite.id}>{invite.email}</li> ))} </ul> ) : ( <p>No invites yet.</p> )} </div> ); }

In Elements, the same template is HTML, with the condition and the loop written as attributes:

app/pages/invites/template.ehtmlElements
import type { Invite } from "#app/shared/services/invites"; <html ( invites: Invite[], private form: { email: string } = { email: "" }, )> <div class="invites"> <label for="email">Email</label> <input id="email" type="email" value={form.email}> <ul e:if={invites.length > 0}> <li e:for={invite of invites}>{invite.email}</li> </ul> <p e:else>No invites yet.</p> </div> </html>

The attributes are declared on the opening tag, the input is bound to form.email in both directions, and the list renders on the server with its rows in the first response.

Conditions and Loops Are Attributes

JSX accepts only expressions between braces, so it has no if and no for. React's docs write conditions as {cond ? <A /> : <B />} and {cond && <A />} (conditional rendering), and the same page warns against a mistake the syntax invites: "Don't put numbers on the left side of &&," because messageCount && <p>New messages</p> "really renders the 0 itself!" Lists are .map() calls, and "JSX elements directly inside a map() call always need keys!" (rendering lists). A nested condition inside a loop inside a condition becomes nested parentheses inside the markup.

Elements HTML puts control flow on the element it controls (directives). e:if, e:elseif and e:else sit on sibling elements, e:switch holds a value and its children carry e:case and e:default, and e:for iterates any iterable with of or in. A condition decides whether its element renders and is never rendered itself, so there is no stray 0 on the page. Rows key themselves by their id field, and e:key takes a typed key function for rows without one. When the list changes, the runtime patches the DOM at the row level, so a row present before and after keeps its elements.

Inputs Bind Both Ways

A form is mostly inputs, and in React each one is two pieces of wiring. "Every controlled input needs an onChange event handler that synchronously updates its backing value," says React's input reference, and the same page warns: "If you pass value without onChange, it will be impossible to type into the input."

In Elements HTML, value, checked and group are two-way bindings on <input>, <textarea>, <select> and contenteditable elements (form bindings). Typing updates the bound field, and setting the field updates the input. A number input binds a number, a file input binds File[], and a set of checkboxes binds one array through group. Nothing marks a value as state: when data changes, the markup that reads it updates.

Typed Templates, Checked by One Build

In TSX, a component is a function and its props are a type you write beside it (React with TypeScript), with React's element types installed from @types/react. In a React project on Vite, the dev server "does NOT perform type checking"; Vite's docs suggest running tsc --noEmit --watch in a separate process (Vite features).

An Elements template declares its attributes in a template constructor, a TypeScript extension written on the opening tag (attributes). Attributes are public or private, can be optional, and can have defaults that read other attributes. A template is called like an HTML element, and a child that changes an attribute it received updates the parent with no callback in between:

app/pages/plan/template.ehtmlElements
type Status = "trial" | "active" | "canceled"; interface Plan { name: string; status: Status; seats: number; } <PlanCard (plan: Plan)> <article class="plan"> <h2>{plan.name}</h2> <span e:switch={plan.status}> <b e:case="trial">Trial</b> <b e:case="active">Active</b> <b e:default>Canceled</b> </span> <label> Seats <input type="number" min="1" value={plan.seats}> </label> </article> </PlanCard> <html (plans: Plan[])> <PlanCard e:for={plan of plans} {plan}/> <p>Total seats: {plans.reduce((sum, p) => sum + p.seats, 0)}</p> </html>

Changing the seats in one card updates the total on the page. Passing plan={plan.name} fails the build with "Argument of type 'string' is not assignable to parameter of type 'Plan'."

Templates are part of the TypeScript program, not a layer beside it (TypeScript). The checker that checks .ts files checks every expression in braces, every attribute and every loop binding, and event handler parameters are typed per element and per event (events). The same file can hold an @rpc function that runs on the server and the event handler that calls it from the browser, and the build checks each call: browser code that reaches sql() directly fails with "Security error: cannot call server code from the browser without going through an rpc function." The editor runs the checker the build runs (editors), so rename, find references and go to definition work across templates and TypeScript alike, and elements build -json reports template errors in the same answer as failing tests and migrations.

Rendered on the Server, Reactive in the Browser

JSX does not decide how a page reaches the browser; the library and framework around it do. In React without a framework, createRoot renders into an empty element, and React's docs say that "when your HTML is empty, the user sees a blank page until the app's JavaScript code loads and runs" (createRoot). Server rendering takes react-dom/server on the server and hydrateRoot in the browser, which "expects the rendered content to be identical with the server-rendered content" (hydrateRoot), or a framework that sets up both for you. React's runtime and the frameworks around it have a page of their own.

Elements HTML renders on the server and in the browser by design (server rendering):

  • Every page is server-side rendered. The first response is complete HTML, with the route's data already in it, for search engines and first-time visitors alike.
  • The page is reactive in the browser. The Elements runtime attaches to that HTML. Event handlers run in the browser, inputs bind both ways, and when data changes the runtime patches the DOM in place, at the smallest level the change requires (reactivity).
  • UI is not streamed from the server. After the first response, the server never renders an update and never sends HTML. An @rpc call, a channel message and a LiveTable change each return data, and the browser renders that data with the page's own templates.
  • LiveView is a LiveTable view. It holds one request's rows in the browser and keeps them in sync as row data. It is unrelated to Phoenix LiveView, and no HTML comes from the server to update it.

Each page arrives as complete HTML, and the same page is fully reactive in the browser, with nothing to configure to get both.

An Agent Already Writes HTML

The case for JSX rests on React's reach: the components and libraries written in it. AI agents such as Claude Code, Codex and Cursor now write a growing share of UI code, and for an agent, a component package helps only when it does something the agent cannot write quickly from HTML, CSS and TypeScript. That is a short list. A dropdown is a <details> element and a few lines of CSS, which an agent writes faster than it can pick a dropdown component, learn its props and restyle it, and each package past that short list is another API to read and another seam to get wrong.

JSX adds a cost of its own: one syntax with several meanings. In React, class is replaced by className, while in Preact it is the spelling "most Preact developers prefer" (Preact). In React, onChange on an input fires on every keystroke (input); in Preact core it is the DOM change event, fired when the value is committed. An agent writing .tsx has to know which library the file is for before it knows what the code does. HTML means the same thing in every browser, and it is the markup an agent has seen most.

Some framework is worth having. No agent writes machine code for a web page, and none wants a package to subtract two numbers. Between those ends sits the plumbing every app needs, and the Elements app framework ships it: routes with app.route(), typed server calls with @rpc, session.login() and sql(). Past that point each abstraction costs an agent more to learn than it saves, so pages stay HTML and CSS and server calls stay TypeScript function calls. Elements puts its engineering into correction instead. The project server holds the build graph and build state in memory, so on every save the build state is updated almost instantly, and elements build -json reports every build error, from type checking, tests, migrations and program analysis, in microseconds (build). A mistake in a template comes back in the same answer as a mistake in a query.

Moving From JSX

An agent can port JSX components to Elements HTML easily. Each component becomes a template with a constructor in place of its props type, ternaries and .map() calls become e:if and e:for, className and htmlFor go back to class and for, and useState with onChange becomes a bound field. You keep TypeScript, your CSS and your npm packages, and every page you port arrives as complete HTML and stays reactive in the browser.

Questions

What is the difference between Elements HTML and JSX?

JSX is a syntax extension to JavaScript that compiles to function calls, and its behavior comes from whichever library compiles it, such as React, Preact or Solid. Elements HTML is a language extension to HTML plus a runtime: standard HTML with TypeScript expressions in {...}, typed templates and e:if, e:for and e:switch attributes, compiled and type checked by the Elements compiler. Attributes are written as in HTML, inputs bind both ways, and every page renders on the server and is reactive in the browser.

Is Elements HTML rendered on the server or in the browser?

Both. Every page's first response is complete HTML rendered on the server. The Elements runtime then attaches in the browser, where event handlers run, inputs bind both ways and the runtime patches the DOM in place when data changes.

Does Elements stream UI updates from the server?

No. After the first response the server never renders an update or sends HTML. Calls to the server through @rpc functions, channels and LiveTable return data, and the browser renders it with the page's own templates. Elements' LiveView type is a LiveTable view, a set of rows held in the browser and kept in sync, unrelated to Phoenix LiveView.

Can I use JSX or React components in Elements?

No. Elements is not React compatible, because its pages are HTML rather than JavaScript that imitates HTML. That buys markup that pastes in as written, two-way input binding, conditions and loops as attributes, server rendering on every page and one build that checks templates with the rest of the app. An agent ports a React component to an Elements HTML template easily.

Is Elements HTML type checked?

Yes. Templates are compiled by the Elements compiler, so expressions, template attributes, loop bindings and event parameters are checked with the rest of the app. The editor uses the same checker as the build, and elements build -json reports template errors with test and migration results.

Can I paste plain HTML into an .ehtml file?

Yes. Elements HTML is standard HTML, so class, for, style strings, data and aria attributes, SVG attributes and unclosed void elements such as <input> and <br> work as written. You add TypeScript in braces where a value should be dynamic.