RadiantElement
RadiantElement is the main base class for Radiant custom elements.
It covers both common host styles:
- imperative hosts that enhance authored light DOM
- JSX-owned hosts that override
render()and let Radiant own the host view
The distinction is no longer the base class. It is whether the class overrides render().
- If you do not override
render(), the host behaves like an authored light-DOM custom element and keeps its children visible. - If you do override
render(), Radiant enables the host render lifecycle:update(),requestUpdate(), SSR helpers, hydration, and slot projection.
It gives you one consistent host API:
- reactive properties and fields
- reactive attribute-backed fields through
@attr(...) @bindTofor copying fields onto existing light DOM- update callbacks for procedures that are not a straight DOM copy
- delegated event wiring
- context integration
- JSX bindings through
bind(...),bindings, and$ - optional string-template rendering through
renderTemplate(...) - host rendering, hydration, and SSR when
render()is overridden
Use RadiantController instead when you want Radiant reactivity attached to existing HTML without defining a custom element.
Two Host Modes
1. Authored Light-DOM Host
This mode keeps the HTML in the document. Use @bindTo to copy reactive fields onto the host or data-ref nodes. Keep @onEvent for clicks and @onUpdated for procedures such as focus.
import { RadiantElement, bindTo, customElement, onEvent, prop } from '@ecopages/radiant';
type CounterProps = {
value?: number;
};
@customElement('radiant-counter')
class RadiantCounter extends RadiantElement {
@prop({ type: Number, reflect: true, defaultValue: 0 })
@bindTo({ ref: 'count', text: true })
value!: number;
@onEvent({ ref: 'decrement', type: 'click' })
decrement() {
if (this.value > 0) {
this.value -= 1;
}
}
@onEvent({ ref: 'increment', type: 'click' })
increment() {
this.value += 1;
}
}Authored HTML stays in place:
<radiant-counter value="5">
<button type="button" data-ref="decrement" aria-label="Decrement">-</button>
<span data-ref="count">5</span>
<button type="button" data-ref="increment" aria-label="Increment">+</button>
</radiant-counter>2. JSX-Owned Host
This mode overrides render() so the custom element owns its host view directly.
/** @jsxImportSource @ecopages/jsx */
import { RadiantElement, customElement, prop } from '@ecopages/radiant';
type CounterBindings = {
value: number;
};
@customElement('radiant-counter')
export class RadiantCounter extends RadiantElement<CounterBindings> {
@prop({ type: Number, reflect: true, defaultValue: 0 }) value = 0;
private readonly decrement = () => {
if (this.value > 0) {
this.value -= 1;
}
};
private readonly increment = () => {
this.value += 1;
};
override render() {
return (
<>
<button type="button" on:click={this.decrement} aria-label="Decrement">
-
</button>
<span>{this.$.value}</span>
<button type="button" on:click={this.increment} aria-label="Increment">
+
</button>
</>
);
}
}Once render() is overridden, RadiantElement exposes the full host render lifecycle:
update()to run the update cycle now: batched@onUpdatedcallbacks, the current JSX view, thenupdated()(without arender()override it still flushes@onUpdatedandupdated())requestUpdate()to schedule that cycle in a microtaskupdateCompleteto wait until the cycle, including the first connect render, finishesrenderViewToString()for the rendered view only (requires a Radiant server SSR entry import)hydrate()when SSR markers exist and the explicit client hydrator is installed- light-DOM slot projection through literal
<slot>tags
Full host HTML (<my-element>...</my-element>) is produced by the server pipeline — prefer renderComponent(...) from @ecopages/radiant/server/render-component. See Component SSR.
Render Lifecycle Gate
RadiantElement only runs the host render lifecycle when render() is overridden.
- The base
render()returns<slot />. - A plain
RadiantElementinstance with no override keeps authored children visible and does not callupdate()on first connect. - An overridden
render()opt-in turns the host into a JSX-owned render boundary.
This means the correct question is not "element or component?" anymore. The question is whether the host should keep authored DOM or own a JSX view.
Connect-time sync
Override protected onConnected() for work that must wait until authored attributes are visible and (when the host owns render()) the initial hydrate/update has committed.
Do not queue a queueMicrotask from connectedCallback for that. onConnected is invoked at that same point, on every connection — not once per instance. Rebuild controllers, observers, and listeners here if disconnectedCallback tears them down. Guard once-only bootstrapping with an explicit flag.
Keep synchronous setup (event listeners, MutationObserver) in connectedCallback. Work that needs the committed DOM, such as moving focus, belongs in updated().
onConnected is not registerConnectedCallback(). Those reactive-host callbacks run synchronously at the start of connectedCallback, before attribute catch-up.
Most elements do not need this hook. Do not use it to copy a reactive value into JSX-owned DOM; use a JSX binding. Do not use it to copy a value onto parent-authored data-ref nodes; use @bindTo. Use @onUpdated(...) when a property change must run a procedure (focus, timers, joined ARIA). Reach for onConnected() only when first-connect setup genuinely needs the synchronized attributes or committed DOM, and must be repeated after a reconnect. @query(...) is unrelated: use it only when that setup needs a live element handle.
Bindings And Reactive Reads
RadiantElement exposes three ways to wire reactive values into JSX:
this.valuefor the raw valuethis.bind('value')for an explicit JSX bindingthis.bindings.valueorthis.$.valuefor property-style JSX bindings
Use the raw value in imperative code and render logic. Use bindings in stable JSX leaf positions such as text, attributes, aria, data, and boolean props. On hosts that do not override render(), use @bindTo for the same leaf writes against existing DOM.
Derived Bindings
Bindings expose the current reactive value, but sometimes JSX needs a projection of that value — a record lookup, an object key, or a transform.
Member access in JSX
For object-like bindings, use member access directly in render():
render() {
return <p>{this.$.config.label}</p>;
}This is sugar for this.$.config.map((config) => config.label). The runtime memoizes each key on the binding, so repeated reads in render() reuse the same derived binding identity.
Use this for simple object-key reads. You do not need a field initializer for {this.$.config.label} in normal components.
map for transforms and lookups
Use map when the projection is not a plain property read — record lookups, computed strings, or method calls:
private readonly themeLabel = this.$.preference.map((preference) => THEME_CONFIG[preference].label);
render() {
return <p>Theme: {this.themeLabel}</p>;
}Create map results once (field initializer or cached host field). Calling .map(...) inside render() creates a new binding on every pass, which breaks the live-subscription fast path.
// Avoid — new derived binding every render
render() {
return <p>{this.$.preference.map((p) => THEME_CONFIG[p].label)}</p>;
}Rules
- Member access (
this.$.config.label) — fine inline in JSX for simple keys. map— hoist transforms and lookups to a create-once host field.- Object props are shallow — projections update when the whole object is replaced (
this.config = { ... }), not when nested keys are mutated in place. - Bracket lookups need
map— usethis.$.preference.map((p) => THEME_CONFIG[p].label), notTHEME_CONFIG[this.$.preference].
Advanced Host Integration
Every reactive host member (@state, @prop, @attr, and signal()) is backed by a signals State inside the host. Decorator APIs are unchanged, but advanced integrations can work with that member registry directly:
createReactiveMember(name, initialValue)— create and register host-owned member stateregisterReactiveMember(name, signal)— register externally owned state, such as a usersignal()getReactiveMember(name)— read the member state registered for a property name
Use these when you build host adapters, tests, or custom decorators. Prefer @state, @prop, @attr, and signal() in application code.
Removed in 0.3.0: trackReactiveRead(...), registerReactiveDependencyReader(...), and the exported ReactiveField metadata type. Dependency tracking now flows through member State.get() and JSX bindings adapted from that state.
When To Use RadiantElement
- Use
RadiantElementwhen you want a custom element host, whether the host is imperative or JSX-owned. - Override
render()when the host itself should own a JSX view. - Skip
render()when the host should enhance authored light DOM instead. - Override
onConnected()for post-catch-up connect work; keep listeners inconnectedCallback. - In JSX-authored composite libraries, keep parent-owned children in the view shell; do not re-project them through
<slot>(see Slots — JSX-authored composites). - Use RadiantController when the DOM is authored elsewhere and a custom element wrapper would be unnecessary.