</> Ultima

Guide

Every Ultima component extends, directly or indirectly, ComponentBase. Components that need declarative data binding extend ReactiveComponent, which in turn extends ComponentBase.

ComponentBase

Handles loading the external HTML template, optional isolation via Shadow DOM, and resolving paths relative to the component's own module (not to the page that uses it).

constructor(properties, subclassUrl)

Reacting to loading: onLoad()

Override onLoad() to run initialization once the component — and all of its descendants that are also ComponentBase — finish mounting. It's called exactly once per instance, directly at the point the framework determines loading is complete, so there's no event to subscribe to and no filtering to do yourself:

onLoad() {
    super.onLoad();
    // the component itself finished loading — safe to read this.rootNode, set initial state, etc.
}

ReactiveComponent itself overrides onLoad() to trigger the first render() — subclasses that override it too must call super.onLoad(), the same rule as any other overridden hook.

The loaded event (for code outside the component)

ComponentBase.LOADED_EVENT ("component-loaded") is the event backing onLoad(). It bubbles (bubbles:true, composed:true) and crosses Shadow DOM boundaries, which is what lets a parent component know when a nested child is done. Code that isn't the component's own class (e.g. the page that uses it) can't override onLoad(), so use element.whenLoaded() instead — it returns a Promise that resolves once, whether the element is already loaded or not:

brTest.whenLoaded().then(() => {
    // the brTest element itself finished loading
});
Why not just addEventListener(ComponentBase.LOADED_EVENT, ...)? The event bubbles from any descendant, not just the element the listener is on, and when a descendant has its own Shadow Root the browser retargets it — event.target shows up as the listening element itself even though the event came from inside the descendant. whenLoaded() already filters that out internally via event.composedPath()[0] and removes its listener after resolving once — this has already caused real re-render loops in this project when done by hand, which is why it's now built in rather than a pattern to repeat per component.

ReactiveComponent

Adds declarative reactivity via data-* attributes read from the template — the full list is in the DSL reference. Here, the concepts behind it.

State

get state()             // returns the current state
set state(newState)     // writes and re-renders, if anything changed

The data-state HTML attribute is also observed — changing it from the outside (setAttribute) has the same effect as using the state setter. Writes that don't change the content (compared by value, not by reference) are ignored — they don't trigger a re-render or events.

Computed properties and watchers

computed()     // -> Object, derived from this.state, merged into every binding
watchers()   // -> { "path": (newValue, oldValue) => {...} }

Override computed() to declare values derived from the raw state — the return value is available to any binding (data-bind, data-if, data-class, data-style, data-for) as if it were a regular piece of state. Override watchers() to react whenever a specific path's value changes between one render and the next.

Scope

Every nesting level — the component's root, a resolved conditional branch, a list item — has its own data scope. Inside a list item, the loop variable's name is available as if it were a regular field, and whatever comes from the parent scope stays accessible (scope chains by prototype). Two nested reactive components don't share scope: a child receives data explicitly via data-bind='{"data-state":"path"}'.

List reconciliation

data-for can reconcile by key — when the item has a stable identity field (declared, or inferred from uuid/id), existing DOM nodes are reused and moved instead of being destroyed and recreated on every render, preserving focus, scroll position and nested components' internal state. Full details and syntax in the Reference.