{"slug": "the-model-in-one-page", "title": "The model in one page", "summary": "A developer outlined a minimal web-component model for coding agents built on @relax.js/core, using native custom elements with no base class or decorators. The approach relies on the documented HTMLElement lifecycle and literal customElements.define tag strings so agents can grep routes, tests, and markup, while warning that type-only imports can elide module execution and that async connectedCallback promises are ignored by the browser.", "body_md": "Third in a series on using [@relax.js/core](https://www.npmjs.com/package/@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.\n\nNo base class. No decorator that registers it. `extends HTMLElement`, the native lifecycle, `customElements.define` at the bottom of the file.\n\n```\nexport class ProfileHeader extends HTMLElement {\n    private name!: HTMLElement;\n\n    connectedCallback() {\n        this.innerHTML = '<header><strong class=\"display-name\"></strong></header>';\n        this.name = this.querySelector('.display-name')!;\n        document.addEventListener(ProfileSavedEvent.type, this.onProfileSaved);\n    }\n\n    disconnectedCallback() {\n        document.removeEventListener(ProfileSavedEvent.type, this.onProfileSaved);\n    }\n\n    private onProfileSaved = (e: ProfileSavedEvent) => {\n        this.name.textContent = e.displayName;\n    };\n}\n\ncustomElements.define('profile-header', ProfileHeader);\n```\n\nWhy 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.\n\nOne 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.\n\n`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.\n\nThe 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:\n\n```\nasync loadRoute(data: RouteParams) {\n    this.userId = String(data.userId);\n\n    this.appendChild(this.template.content);\n    this.template.render({ heading: 'Your profile' }, { discard: () => this.load() });\n\n    this.status = this.statusLine({ text: '' });\n    this.appendChild(this.status.fragment);\n\n    this.form = FormValidator.FindForm(this);\n    this.validator = new FormValidator(this.form, {\n        useSummary: true,\n        submitCallback: () => this.save(),\n    });\n\n    await this.load();\n}\n```\n\nEverything 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.\n\nThere 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:\n\n``` js\nprivate async save() {\n    const profile = readData<Profile>(this.form);\n    const response = await put(`/users/${this.userId}`, JSON.stringify(profile));\n    if (!response.success) {\n        this.validator.addErrorToSummary('Save', `The server rejected the change (${response.statusCode})`);\n        return;\n    }\n    this.status.update({ text: 'Saved' });\n    this.dispatchEvent(new ProfileSavedEvent(this.userId, profile.displayName));\n}\n```\n\nThe 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.\n\nThis 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.\n\nComponents do not call each other. The page dispatches, the header listens, and neither imports the other. The thing they share is the event class:\n\n```\nexport class ProfileSavedEvent extends Event {\n    static readonly type = 'profile-saved';\n\n    constructor(\n        public readonly userId: string,\n        public readonly displayName: string,\n    ) {\n        super(ProfileSavedEvent.type, { bubbles: true });\n    }\n}\n\ndeclare global {\n    interface HTMLElementEventMap {\n        [ProfileSavedEvent.type]: ProfileSavedEvent;\n    }\n    interface DocumentEventMap {\n        [ProfileSavedEvent.type]: ProfileSavedEvent;\n    }\n}\n```\n\nNot `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.\n\nGrep `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.\n\nNo 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\".\n\nFour 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.", "url": "https://wpnews.pro/news/the-model-in-one-page", "canonical_source": "https://dev.to/jgauffin/the-model-in-one-page-5cpm", "published_at": "2026-09-22 17:45:15+00:00", "updated_at": "2026-09-22 17:53:00.690304+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["@relax.js/core", "MDN", "vitest", "HTMLElement", "customElements.define"], "alternates": {"html": "https://wpnews.pro/news/the-model-in-one-page", "markdown": "https://wpnews.pro/news/the-model-in-one-page.md", "text": "https://wpnews.pro/news/the-model-in-one-page.txt", "jsonld": "https://wpnews.pro/news/the-model-in-one-page.jsonld"}}