Reactive HTML without React
Elements ships with its own reactive HTML language. It is plain HTML, with TypeScript expressions in braces and templates that take typed attributes the way a function takes parameters. Change the data and the page updates. There is no React, no virtual DOM and no state hooks. This post is the short tour. The video above goes deeper, with the React comparison and a look at how the runtime does it.
Here is a complete page.
app/pages/home/template.ehtml<html (title: string, private count: number = 0)> <h1>{title}</h1> <button onclick={() => count++}>Clicked {count} times</button> </html>
The () after the tag name declares the constructor attributes. They are typed like function parameters. title is public, so the route or a parent template sets it. count is private, so the template owns it and callers cannot set it. Both are reactive automatically. The {count} in the button body updates when the click handler increments it, and nothing else on the page is touched. There is no useState, no setter, no effect and no dependency list to get wrong.
The route fills the public attributes, often from the database.
app/pages/home/index.tsimport { Request, Response } from "@elements/app"; import html from "./template"; export default function route(req: Request, res: Response) { return new html({ title: req.params.title ?? "Hello World!" }); }
The page renders on the server, so the first response is the finished HTML. The runtime then attaches to that HTML in the browser. It does not throw the page away and rebuild it. From there the page is reactive: handlers run in the browser, and changes patch the DOM in place.
Any top-level tag with a capitalized name is a template. Templates take constructor attributes too, and you call one like an HTML tag.
app/pages/home/template.ehtml<html (title: string)> <Welcome greeting={title} /> </html> <Welcome (greeting: string)> <h1>{greeting}</h1> </Welcome>
Templates are TypeScript symbols. Pass an attribute the template does not declare, or the wrong type, or leave out a required one, and the build reports an error. Rename and find references work across templates in your editor the same way they work across functions.
Loops and conditions are attributes on the element itself, prefixed with e:.
app/pages/home/template.ehtml<ul> <li e:for={user of users}>{user.name}</li> </ul> <p e:if={users.length === 0}>no users yet</p> <p e:else>{users.length} users</p>
e:for iterates any iterable and keys the rows by id, so when one row changes, one row is patched, and a row that moved is moved rather than re-rendered. e:if, e:elseif and e:else are siblings. There is an e:switch with e:case and e:default for the longer cases.
Form inputs are bound both ways. Type in the input and the variable changes. Change the variable and the input changes.
app/pages/home/template.ehtml<Login (private form: LoginForm = { email: "", password: "" }, private error: string = "")> <form onsubmit={() => error = login(form)}> <input value={form.email}> <input type="password" value={form.password}> <p e:if={error} class="error">{error}</p> <button>Sign in</button> </form> </Login>
That is the login form, nothing left out. The same form in React needs a useState for each field, an onChange handler for each input and a setter call in each handler. Here the reactive parts are the expressions in braces, and the runtime does the rest.
A class attribute takes an array, and falsy entries are dropped, so a class comes and goes with a boolean.
app/pages/home/template.ehtml<button class={[active && "is-primary"]} onclick={() => active = !active}>Toggle Me</button>
Under the hood there is no virtual DOM. The runtime knows which data each binding read, and when that data changes it patches that one text node, attribute or element. There is no component function to re-run, no tree to rebuild and no diff to compute. Work is proportional to the change, not to the size of the page.
The page's attributes are live in the browser console as $. Assign to one and the page updates through the same path the template uses, which is a quick way to see any state of the UI without clicking through it.
browser console> $.title = "Boom!";
The language is built into the TypeScript toolchain inside the Elements project server, so a template is a TypeScript symbol. Go to definition, find references and rename work across templates in your editor the same way they work across functions, and every attribute is type checked.
Why not React? When this language started in 2012, React did not exist yet. The idea was that a user interface should reflect its data automatically, and that the reactivity belongs in the runtime, not in the code you write. React moved the other way. Getting started means learning a stack of abstractions, hooks, effects, dependency lists and re-render rules, and the APIs keep changing. Beginners get lost in it, and experienced developers still get it wrong in ways that show up as bugs and slow pages. I wanted our users to write HTML and have the runtime do the rest.
That decision turned out to matter for AI agents. An agent has seen far more HTML than JSX, and with lexically scoped constructor attributes and automatic reactivity it spends its effort on the presentation rather than on rendering mechanics. In practice that means fewer tokens and fewer bugs in the interface. Humans find the code easier to read too.
Every new app also installs Elements UI, a style system with about 80 templates for the parts every application needs: forms, overlays, data tables, charts, chat. The agent builds well with the raw language alone, and the templates mean it does not reinvent a date picker every time.
The manual has the full language at elements.dev/man/html (opens in a new tab), and the templates are at elements.dev/ui (opens in a new tab). Questions go in the comments.
1 comment
Good job boss. Read it twice. No mistakes detected. I for one, love coding in Elements HTML. 🤖
Add a comment