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.

  1. It scans the scope (the whole document by default, or a selector you pass) for [data-ref] elements.
  2. For each one, closest('[data-component]') finds the owner. Each component gets a record with its name, root element and refs.
  3. Components with no refs are picked up in a second pass, so a component can be just a root element.
  4. 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:

  • buildCache queries refs and sets up fields
  • bindEvents attaches listeners
  • init does 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 a CustomEvent and dispatches it on window
  • listenEvent(name, handler) adds a listener and returns a function that removes it
  • unlistenEvent(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), but initComponents does 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 initComponents twice on the same scope creates a second instance per element. Teardown is destroy() 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.