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(...) - update callbacks
- 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 and uses decorators plus imperative DOM reads to wire behavior.
import { RadiantElement, customElement, onEvent, onUpdated, query, prop } from '@ecopages/radiant';
type CounterProps = {
value?: number;
};
@customElement('radiant-counter')
class RadiantCounter extends RadiantElement<CounterProps> {
@prop({ type: Number, reflect: true, defaultValue: 0 }) value!: number;
@query({ ref: 'count' }) countText!: HTMLSpanElement;
@onEvent({ ref: 'decrement', type: 'click' })
decrement() {
if (this.value > 0) {
this.value -= 1;
}
}
@onEvent({ ref: 'increment', type: 'click' })
increment() {
this.value += 1;
}
@onUpdated('value')
updateCount() {
this.countText.textContent = String(this.value);
}
}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 apply the current JSX viewrequestUpdate()for coalesced rerendersrenderViewToString()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.
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.
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. - Use RadiantController when the DOM is authored elsewhere and a custom element wrapper would be unnecessary.