Most pages I build with Astro are static HTML with a few interactive parts: a menu, a modal, a theme switch. The JavaScript for them usually starts as one script per feature, each with its own querySelector calls and listeners. That works until two features want the same element, a listener needs removing, or someone asks where the code for this button lives.
What helps is the structure components give: one unit per piece of UI, with its own elements, its own setup and a clear start and end. It does not need a framework or a virtual DOM. The HTML already exists, so the only missing piece is a way to attach a unit of code to it.
I wrote Nux, a zero-dependency, TypeScript-first layer that does this, and this site uses it. A component is a class. The markup says where it lives:
<div data-component="counter">
<button type="button" data-ref="counter:plus">+</button>
<span data-ref="counter:result">0</span>
</div>import { Component, defineComponent } from '@zoxon/nux'
class Counter extends Component {
plus?: HTMLButtonElement
result?: HTMLElement
count = 0
buildCache() {
this.plus = this.get<HTMLButtonElement>('plus')
this.result = this.get('result')
}
bindEvents() {
this.plus?.addEventListener('click', () => {
this.count++
this.render()
})
}
init() {
this.render()
}
render() {
if (this.result) this.result.textContent = String(this.count)
}
}
defineComponent('counter', Counter)The class API is small: get, getAll, three lifecycle hooks and destroy. The init system is the more interesting part.
How init works
initComponents() runs in four steps.
- It scans the scope (the whole document by default, or a selector you pass) for
[data-ref]elements. - For each one,
closest('[data-component]')finds the owner. Each component gets a record with its name, root element and refs. - Components with no refs are picked up in a second pass, so a component can be just a root element.
- For each record it looks up the class in the registry, instantiates it and awaits
init().
The result is a plain array of { name, rootElement, refs, dependencies, instance }. There is no virtual DOM and no reactive state, just a registry and a DOM query.
Using closest() means refs belong to the nearest component, so nesting works without extra rules. A modal inside a page component never leaks its refs to the page. The name:ref prefix in data-ref does the rest: get('plus') looks for counter:plus, so two components on one page cannot collide.
Three phases
The constructor runs buildCache() and then bindEvents(). After that, initComponents() calls init() and awaits it. That gives each phase one job:
buildCachequeries refs and sets up fieldsbindEventsattaches listenersinitdoes the first render or any async setup
Because init() can return a promise and the loop awaits each component, a component can fetch data before the next one starts. The cost is that a slow init() delays everything after it. Put slow work in a background call if the next component does not need it.
Astro
The Astro integration injects one script with injectScript('page', ...). It calls initComponents on DOMContentLoaded, with the scope you set in astro.config.ts. This site sets it to page.
Scope is useful when part of the page changes after load. Call initComponents({ scope: '#results' }) after you swap in new HTML, and only that subtree is scanned.
Any framework, or none
Astro is only a convenience. The core is plain TypeScript with no dependencies, and the Astro integration is a few lines that call initComponents on DOMContentLoaded. Anywhere else you make that call yourself:
import { initComponents } from '@zoxon/nux'
import './components' // files with defineComponent(...)
document.addEventListener('DOMContentLoaded', () => {
initComponents({ scope: 'page' })
})That works for static HTML, a server-rendered backend (Rails, Django, Laravel and the like) and a CMS template. It also works inside a React, Vue or Svelte app for server-rendered fragments, as long as you call initComponents with a scope after the markup is in the DOM. The package also has a @zoxon/nux/vanilla entry point, a default export that wraps the same call.
Events between components
Nux has no event bus. Components talk through native custom events on window, with @zoxon/eventor as a typed wrapper over three calls:
dispatchCustomEvent(name, detail)creates aCustomEventand dispatches it onwindowlistenEvent(name, handler)adds a listener and returns a function that removes itunlistenEvent(name, handler)removes a listener by hand
Event names and payloads are declared once by extending WindowEventMap:
declare interface WindowEventMap {
'modal:show': CustomEvent<{ id: string }>
'modal:close': CustomEvent<{ id: string }>
}After that, event names autocomplete and detail is typed on both sides:
import { Component } from '@zoxon/nux'
import { dispatchCustomEvent, listenEvent } from '@zoxon/eventor'
class Trigger extends Component {
bindEvents() {
this.get('open')?.addEventListener('click', () => {
dispatchCustomEvent('modal:show', { id: 'newsletter' })
})
}
}
class Modal extends Component {
private unlisten?: () => void
bindEvents() {
this.unlisten = listenEvent('modal:show', ({ detail }) => {
if (detail.id === this.element.id) this.open()
})
}
destroy() {
this.unlisten?.()
super.destroy()
}
}I like this for three reasons. There is nothing to maintain, because the browser already has the dispatch and subscribe machinery. DevTools can show the listeners. And the events work for code outside Nux, such as a third-party script that wants to open the modal.
The trade-off is that window is one global namespace, so I prefix event names with the component (modal:show). The dispatch is also synchronous, and a listener that throws does not stop the other listeners. Keep payloads small and serializable, and clean up with the function listenEvent returns.
Limits
- Components with no registered class are skipped without a warning.
- The map records
dependencies(nested components with a different name), butinitComponentsdoes not use them for ordering. Components start in the order the scan found them: those with refs first, then those without. A parent that needs its child to be ready should not rely on that order. - Calling
initComponentstwice on the same scope creates a second instance per element. Teardown isdestroy()on each instance. There is no “destroy everything” helper yet.
I’m fine with those limits. Nux does not try to replace a framework. It covers the case where the HTML already exists and needs behavior attached to it.