Radiant0.3.0-rc.2

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 view
  • requestUpdate() for coalesced rerenders
  • renderViewToString() 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 RadiantElement instance with no override keeps authored children visible and does not call update() 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.value for the raw value
  • this.bind('value') for an explicit JSX binding
  • this.bindings.value or this.$.value for 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 — use this.$.preference.map((p) => THEME_CONFIG[p].label), not THEME_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 state
  • registerReactiveMember(name, signal) — register externally owned state, such as a user signal()
  • 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 RadiantElement when 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.

See Also