DEV Community

Jonas Gauffin
Jonas Gauffin

Posted on

The model in one page

Third in a series on using @relax.js/core with a coding agent. This one is the model the agent has to hold in its head. It is short on purpose. Every code block below is from a small example app that runs under vitest; nothing here is sketched.

1. A component is a plain custom element

No base class. No decorator that registers it. extends HTMLElement, the native lifecycle, customElements.define at the bottom of the file.

export class ProfileHeader extends HTMLElement {
    private name!: HTMLElement;

    connectedCallback() {
        this.innerHTML = '<header><strong class="display-name"></strong></header>';
        this.name = this.querySelector('.display-name')!;
        document.addEventListener(ProfileSavedEvent.type, this.onProfileSaved);
    }

    disconnectedCallback() {
        document.removeEventListener(ProfileSavedEvent.type, this.onProfileSaved);
    }

    private onProfileSaved = (e: ProfileSavedEvent) => {
        this.name.textContent = e.displayName;
    };
}

customElements.define('profile-header', ProfileHeader);
Enter fullscreen mode Exit fullscreen mode

Why it matters for an agent: the lifecycle is documented on MDN, which the agent has read more of than any framework's docs. And customElements.define('profile-header', ...) is a literal string, so the route table, the test and the HTML that use profile-header are all one grep away.

One trap I hit while writing the example, and it is worth knowing: if a test file imports the class only as a type (navigate<ProfilePage>(...)), the bundler elides the import, the module never runs, and the tag is never defined. Import the module for its side effect: import '../src/pages/ProfilePage'. defineRoutes fails fast with the tag name when this happens, which is how I noticed.

2. The lifecycle is synchronous

connectedCallback returns before anything you await in it has finished. Marking it async compiles, and the browser ignores the promise. This is the first habit an agent brings from ngOnInit and onMounted, where the framework at least knows you started something.

The pattern is: do the synchronous part, kick off the async part, and let the async part update the DOM when it lands. In the example app the profile page does its loading in loadRoute, which the router does await, so it looks like this:

async loadRoute(data: RouteParams) {
    this.userId = String(data.userId);

    this.appendChild(this.template.content);
    this.template.render({ heading: 'Your profile' }, { discard: () => this.load() });

    this.status = this.statusLine({ text: '' });
    this.appendChild(this.status.fragment);

    this.form = FormValidator.FindForm(this);
    this.validator = new FormValidator(this.form, {
        useSummary: true,
        submitCallback: () => this.save(),
    });

    await this.load();
}
Enter fullscreen mode Exit fullscreen mode

Everything above the await is on the page before the request goes out. A test that mounts the component and asserts immediately sees the empty form; one that waits sees the data. There is a helper for the waiting, in the fifth article.

3. Nothing re-renders on its own

There is no reactive state. When data changes, update the DOM at that point. The header above does it with textContent. The page does it with a second, tiny template for the only part that changes after load:

private async save() {
    const profile = readData<Profile>(this.form);
    const response = await put(`/users/${this.userId}`, JSON.stringify(profile));
    if (!response.success) {
        this.validator.addErrorToSummary('Save', `The server rejected the change (${response.statusCode})`);
        return;
    }
    this.status.update({ text: 'Saved' });
    this.dispatchEvent(new ProfileSavedEvent(this.userId, profile.displayName));
}
Enter fullscreen mode Exit fullscreen mode

The form itself is rendered once and never again, because every render writes value back into the inputs and would replace what the user is typing. The native form is the state. readData reads it, setFormData writes it. There is no mirror of the field values anywhere in the class.

This is the rule that costs the most when you come from Vue. It is also the one that makes the diff say what happens. An agent that adds a field to this page has to add the place where the field is updated, and a reviewer sees both in the same hunk.

4. Events are classes

Components do not call each other. The page dispatches, the header listens, and neither imports the other. The thing they share is the event class:

export class ProfileSavedEvent extends Event {
    static readonly type = 'profile-saved';

    constructor(
        public readonly userId: string,
        public readonly displayName: string,
    ) {
        super(ProfileSavedEvent.type, { bubbles: true });
    }
}

declare global {
    interface HTMLElementEventMap {
        [ProfileSavedEvent.type]: ProfileSavedEvent;
    }
    interface DocumentEventMap {
        [ProfileSavedEvent.type]: ProfileSavedEvent;
    }
}
Enter fullscreen mode Exit fullscreen mode

Not CustomEvent with a detail bag. A class with properties, registered in the event map of whatever you listen on, so addEventListener infers the type and e.displayName is checked. I had to add DocumentEventMap while writing this, because the header listens on document; HTMLElementEventMap alone covers elements only. The compiler told me, which is the point.

Grep ProfileSavedEvent and you have every producer and every consumer in the codebase. That is the whole "shared state" story for a small app, and it is the one the first article promised: the connection between two places is a literal name the agent can search for.

What is deliberately missing

No store. No computed properties. No context or provide/inject. If a value is derived, compute it where the source changes and pass the result on. If two components far apart need the same data, the page that owns it places both, through slots, instead of threading it down. The library's docs/WhyRelaxjs.md argues each of these at length; the skill just says "do not".

Four rules. They fit in the core skill with room to spare, and the agent has them loaded before it writes a line. Next: what happens when the line it writes is wrong, and why nothing throws.

Top comments (0)