DEV Community

Jonas Gauffin
Jonas Gauffin

Posted on Originally published at blog.gauffin.org on

TSX where every tag is a real DOM element

JSX belongs to React in most people's heads. It is a TypeScript compiler feature. TypeScript turns <li className="row">{name}</li> into a call to a function you name, and what that function returns is up to you. React returns a description of an element and reconciles it later. @relax.js/core 2.4 (GitHub) ships a function that returns the element itself.

This article shows what that looks like, the rules it follows, and why the result is easy to debug, for people and for coding agents.

A component

import { h } from '@relax.js/core/jsx';
import { IssueSelectedEvent } from './events';

interface Issue {
    id: string;
    title: string;
    severity: 'low' | 'high';
}

export class IssueList extends HTMLElement {
    private count = <span />;
    private list = <ul className="issues" />;

    connectedCallback() {
        this.replaceChildren(
            <header>
                <h2>Issues</h2>
                {this.count}
            </header>,
            this.list
        );
    }

    show(issues: Issue[]) {
        this.count.textContent = String(issues.length);
        this.list.replaceChildren(
            ...issues.map((issue) => (
                <li className={`issue ${issue.severity}`} data-id={issue.id}>
                    <button onclick={() => this.dispatchEvent(new IssueSelectedEvent(issue.id))}>
                        {issue.title}
                    </button>
                </li>
            ))
        );
    }
}

customElements.define('issue-list', IssueList);

Enter fullscreen mode Exit fullscreen mode

this.list is the <ul> in the page. Not a ref to it, not a node that becomes it after a commit phase. Changing the count is an assignment. Changing the list is a replaceChildren call. Both sit in the method where the data changed, which is the only place anything changes.

What the compiler produces

With "jsx": "react" and "jsxFactory": "h" in tsconfig.json, every tag becomes one function call on the same line:

h('li', { className: `issue ${issue.severity}`, 'data-id': issue.id },
    h('button', { onclick: () => ... }, issue.title))

Enter fullscreen mode Exit fullscreen mode

"react" is only TypeScript's name for this mode. Nothing from React is installed or imported. Vite, esbuild and Vitest read the same two settings.

h creates the element, sets the props, appends the children and returns it. Stripped of types and comments it is short enough to read in one sitting. The heart of it:

const element = document.createElement(type);
for (const [name, value] of Object.entries(props)) {
    if (name === 'on') {
        addListeners(element, value);
    } else if (name.includes('-')) {
        setAttribute(element, name, value);
    } else if (!(name in element)) {
        reportUnknownProperty(element, name);
    } else {
        element[name] = value;
    }
}
append(element, children);
return element;

Enter fullscreen mode Exit fullscreen mode

That is the whole model. There is no second phase to learn.

Four rules

The expression is the element. <input /> runs document.createElement('input'). Keep it in a variable and you can do anything to it that you can do to any element.

One rule for props. A name with a dash (data-id, aria-label) is set with setAttribute. Everything else is assigned to the DOM property of that name. Names are never translated, so you write className, htmlFor and onclick, as the DOM spells them. Every property is typed from lib.dom, so onClick or for is a compile error. If one gets past the types anyway, the runtime reports <button> has no property 'onClick' instead of setting an expando nobody reads.

Components run once. A function used as a tag is called once with its props and returns an element. A custom element class used as a tag is constructed, its props are assigned to its fields, and then it is returned. Props are set before it connects, so connectedCallback sees them:

const card = <UserCard user={user} />;

Enter fullscreen mode Exit fullscreen mode

Using the class instead of the tag name means the import cannot be dropped by the bundler, and a renamed field is a type error at every use.

Nothing is reconciled. Arrays are appended in order. There are no keys and no diffing. To update a list, you replace its children, or you keep the rows you want to update in a map and assign to them.

Custom events go in on, typed from GlobalEventHandlersEventMap, where event classes are registered anyway:

<section on={{ 'issue-selected': (event) => this.open(event.issueId) }}>
    {this.list}
</section>

Enter fullscreen mode Exit fullscreen mode

Why it is easy to debug

The stack trace ends at your line. An exception while building a <li> has one library frame between the throw and the line in your .tsx file. No scheduler, no reconciler, no commit phase.

What you hold is what the browser shows. Hover this.list in the debugger and you see an HTMLUListElement. Select it in the Elements panel and $0 === this.list. There is no fiber or virtual node to map back to the DOM.

Nothing happens later. No batching, no effects, no re-render. If the DOM is wrong, some line in your code wrote it. Set "Break on subtree modification" in DevTools and it stops in your show() method, not inside a framework.

DevTools shows which rule applied. An attribute shows up in the Elements panel, a property in the Properties pane. Nothing was renamed on the way.

Habits from React fail at compile time. onClick, class, for, key and ref are not properties of a DOM element, so they are type errors. In a framework where they are valid, a misplaced one can compile and do nothing.

One React habit does type-check: {issues.length && <IssueList />} renders a 0 when the list is empty, because numbers are children. Write {issues.length > 0 && <IssueList />}; false renders nothing.

The runtime fits in your head, and in a coding agent's context. A coding agent cannot open a browser. It reads files, runs the type checker and runs tests. A runtime it can read in full, next to the bug it is fixing, is worth more to it than any amount of documentation about a scheduler it cannot observe.

The import is in the file

TypeScript has two JSX modes. The automatic one inserts an import of the runtime into every file for you. The classic one calls whatever function you named and expects you to import it. Relaxjs uses classic:

import { h } from '@relax.js/core/jsx';

Enter fullscreen mode Exit fullscreen mode

That line costs one import per file and tells every reader, human or coding agent, where the tags in this file are turned into elements. "Go to definition" on h opens the runtime. Forgetting it is a compile error: Cannot find name 'h'.

The JSX types live on h (h.JSX) rather than the global JSX namespace, so a project that also has React types installed gets no conflicts.

Where it stops

Every tag is typed HTMLElement. TypeScript gives all JSX expressions one type and cannot infer HTMLInputElement from <input />. Cast where you need a member specific to one element:

const email = <input type="email" required /> as HTMLInputElement;
email.value = user.email;

Enter fullscreen mode Exit fullscreen mode

This is the one place where the types know less than the runtime.

No updates in place. The expression builds once. If a value changes, you assign it. That is the point, but it means a screen with many interdependent values is wiring you write by hand.

No fragments. Since every expression is an HTMLElement, <>...</> would be a lie in the types. Return one element, or an array where a child is expected.

Custom tags by name need a declaration. <user-card /> type-checks only if the tag is declared in HTMLElementTagNameMap. Writing the class, <UserCard />, needs nothing.

Which template to use

Relaxjs has two other ways to render markup, and the split is by where the markup lives:

  • Markup that arrives as text , from an HTML file, a <template> element or a server response: compileTemplate. Text cannot be TSX, and compileTemplate gives text loops, conditionals, pipes and event bindings.
  • Markup written in TypeScript , next to the code that uses it: TSX. Loops and conditions are plain TypeScript and every tag is type-checked.
  • No JSX settings in the build , or one block of values rebound in place many times: the html tagged literal.

Setting up a project

A complete app: a service, two pages, the IssueList from the top of this article, routing, and one page that is only downloaded when it is first visited. Start from Vite's plain TypeScript template:

npm create vite@latest issues -- --template vanilla-ts
cd issues
npm install @relax.js/core

Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{
    "compilerOptions": {
        "target": "ES2022",
        "module": "ESNext",
        "moduleResolution": "Bundler",
        "lib": ["ES2022", "DOM"],
        "strict": true,
        "useDefineForClassFields": true,
        "verbatimModuleSyntax": true,
        "jsx": "react",
        "jsxFactory": "h"
    },
    "include": ["src"]
}

Enter fullscreen mode Exit fullscreen mode

jsx and jsxFactory are the TSX lines. verbatimModuleSyntax keeps every import that is not written as import type, so a component imported for its type is not silently dropped from the bundle. Relaxjs decorators are the standard ones, so experimentalDecorators stays off. Vite and Vitest read all of this from the file; no plugin is needed.

index.html

<!DOCTYPE html>
<html>
<body>
    <r-route-target></r-route-target>
    <script type="module" src="/src/main.ts"></script>
</body>
</html>

Enter fullscreen mode Exit fullscreen mode

The body holds only the route target. Pages are created by the router after startup, never by the parser while modules are still loading.

src/issues/IssueApi.ts

import { ContainerService } from '@relax.js/core/di';
import { get } from '@relax.js/core/http';

export interface Issue {
    id: string;
    title: string;
    severity: 'low' | 'high';
    description: string;
}

@ContainerService()
export class IssueApi {
    list(): Promise<Issue[]> {
        return this.read<Issue[]>('/api/issues');
    }

    find(id: string): Promise<Issue> {
        return this.read<Issue>(`/api/issues/${id}`);
    }

    private async read<T>(url: string): Promise<T> {
        const response = await get(url);
        if (!response.success) {
            throw new Error(`GET ${url} failed with status ${response.statusCode}`);
        }
        return response.as<T>();
    }
}

Enter fullscreen mode Exit fullscreen mode

get resolves on a 4xx or 5xx with success: false rather than rejecting, so the check is written out.

src/issues/IssuesPage.tsx

import { h } from '@relax.js/core/jsx';
import { Inject } from '@relax.js/core/di';
import { navigate } from '@relax.js/core/routing';
import { IssueApi } from './IssueApi';
import { IssueList } from './IssueList';

export class IssuesPage extends HTMLElement {
    @Inject(IssueApi)
    private api!: IssueApi;

    private list = <IssueList /> as IssueList;

    connectedCallback() {
        this.replaceChildren(
            <section on={{ 'issue-selected': (event) => this.open(event.issueId) }}>
                {this.list}
            </section>
        );
        this.load();
    }

    private async load() {
        try {
            this.list.show(await this.api.list());
        } catch (error) {
            this.replaceChildren(<p className="error">The issues could not be loaded.</p>);
            reportError(error);
        }
    }

    private open(id: string) {
        navigate('issue', { params: { id } });
    }
}

customElements.define('issues-page', IssuesPage);

Enter fullscreen mode Exit fullscreen mode

@Inject(IssueApi) uses the class as a value, so this module imports the service module, and the service is registered before the page class even exists. The import order in main.ts stops mattering for it.

A failed request shows a message in the page and goes to reportError, so it reaches the console and window.onerror instead of becoming an unhandled rejection. connectedCallback runs each time the element is inserted, so a page that is moved around rather than created by the router would load again each time.

src/issues/events.ts

export class IssueSelectedEvent extends Event {
    static readonly type = 'issue-selected';

    constructor(public readonly issueId: string) {
        super(IssueSelectedEvent.type, { bubbles: true });
    }
}

declare global {
    interface GlobalEventHandlersEventMap {
        [IssueSelectedEvent.type]: IssueSelectedEvent;
    }
}

Enter fullscreen mode Exit fullscreen mode

IssueList dispatches the event on itself and IssuesPage listens on the <section> around it, so bubbles: true is what makes the click reach the page. The map entry is what types event.issueId in the on prop.

src/issues/IssueList.tsx is the component from the top of this article, with Issue imported from the service.

src/issues/IssuePage.tsx

import { h } from '@relax.js/core/jsx';
import { Inject } from '@relax.js/core/di';
import type { LoadRoute } from '@relax.js/core/routing';
import { IssueApi } from './IssueApi';

export class IssuePage extends HTMLElement implements LoadRoute<{ id: string }> {
    @Inject(IssueApi)
    private api!: IssueApi;

    async loadRoute(data: { id: string }) {
        const issue = await this.api.find(data.id);
        this.replaceChildren(
            <article>
                <h1>{issue.title}</h1>
                <p>{issue.description}</p>
            </article>
        );
    }
}

customElements.define('issue-page', IssuePage);

Enter fullscreen mode Exit fullscreen mode

The router awaits loadRoute before it puts the page on screen, so the page never shows half-loaded.

src/main.ts

import { defineRoutes, startRouting } from '@relax.js/core/routing';
import { IssuesPage } from './issues/IssuesPage';

defineRoutes([
    { name: 'issues', path: '/', component: IssuesPage },
    {
        name: 'issue',
        path: '/issues/:id',
        componentTagName: 'issue-page',
        load: () => import('./issues/IssuePage'),
    },
]);
startRouting();

Enter fullscreen mode Exit fullscreen mode

The list page is named by its class, so it is in the startup bundle and a typo is a compile error. The detail page is named by its tag and a load function, so Vite puts it in its own chunk and the router imports it on the first visit to /issues/:id. If the app uses translations, await setLocale(...) goes before defineRoutes().

Point /api at your backend with Vite's server.proxy, run npm run dev, and click an issue. In the Network tab, IssuePage arrives only then.

The full reference is docs/html/jsx.md on GitHub.

Top comments (0)