--- title: "RadiantController" description: "RadiantController adds Radiant reactivity to existing HTML with a controller authoring model close to RadiantElement." group: Components order: 2 --- import { ControllerContextVisualizer } from '@/components/controller-context-visualizer'; import { ControllerDecoratorVisualizer } from '@/components/controller-decorator-visualizer'; export const config = { dependencies: { components: [ControllerContextVisualizer, ControllerDecoratorVisualizer], }, }; # RadiantController `RadiantController` lets you attach Radiant reactivity to existing DOM without defining a custom element. Use it when the HTML already exists in a document, a server template, or CMS-authored markup and you want controller-style behavior on top of that DOM. ## Mental Model - `RadiantElement` owns a custom-element host. - `RadiantController` attaches to an existing element. - controller inputs should come from attributes, typically `data-*` - controller props can also come from real host properties when the surrounding code wants to pass objects, arrays, or other JS values without attribute serialization - use `data-ref` only when the controller needs to read or delegate against HTML it does not own through `render()` - controller authoring is intentionally close to `RadiantElement`: you can define reactive fields, lifecycle callbacks, and `render()` ## Registration Register a controller with `@controller(...)` and start the registry on a root. `startControllers(...)` is available from `@ecopages/radiant/controller-registry`. Prefer that focused subpath when a module only needs registry setup. ```ts import { RadiantController, controller } from '@ecopages/radiant'; import { startControllers } from '@ecopages/radiant/controller-registry'; @controller('search') class SearchController extends RadiantController { connect() { super.connect(); } } startControllers(document); ``` ```html
``` Important: controller identifiers are global. By default, later registrations for an existing identifier are ignored and the first registered controller stays active. If a tooling or HMR environment needs live replacement semantics, use the explicit registry runtime APIs instead of changing default registration behavior. See [Controller Tooling & HMR](/docs/tools/controller-tooling-hmr). If the same module also needs other common Radiant decorators or bases, importing everything from the root entrypoint is still valid. The focused registry subpath is mainly the better default for bundle-sensitive setup code. ## Render Authoring Controllers support the same `render()`, `requestUpdate()`, and `update()` flow used by render-owning `RadiantElement` hosts. When you override `render()`, Radiant renders into the attached host element instead of a custom-element instance. In that mode, prefer ordinary JSX event bindings such as `on:click`. Reach for `@query(...)`, `@onEvent(...)`, `getRef(...)`, and `data-ref` when the controller is enhancing authored DOM, delegating across markup it does not own, or wiring listeners outside the rendered subtree. Use this split: - override `render()` when the controller should own the host's inner DOM - keep authored HTML in place when the server, CMS, or template already owns the markup ## Render-Owned Example ```ts import { RadiantController, attr, controller, state } from '@ecopages/radiant'; @controller('disclosure') export class DisclosureController extends RadiantController { @attr({ source: 'data-open', type: Boolean }) open = false; @state toggles = 0; private readonly toggle = () => { this.open = !this.open; this.toggles += 1; }; override render() { return (
); } } ``` ```html
``` ## Authored Form Example When the host markup already exists, let the controller read stable refs with `@query(...)` and bind behavior with `@onEvent(...)`. ```ts import { RadiantController, attr, controller, onEvent, query, state } from '@ecopages/radiant'; @controller('newsletter-form') export class NewsletterFormController extends RadiantController { @attr({ source: 'data-success-message' }) successMessage = 'Thanks for subscribing.'; @state pending = false; @query({ ref: 'form' }) form!: HTMLFormElement; @query({ ref: 'email' }) emailInput!: HTMLInputElement; @query({ ref: 'status' }) statusNode!: HTMLParagraphElement; @query({ ref: 'submit' }) submitButton!: HTMLButtonElement; @onEvent({ ref: 'form', type: 'submit' }) async submit(event: Event) { event.preventDefault(); const email = this.emailInput.value.trim(); if (!email) { this.statusNode.textContent = 'Enter an email address.'; return; } this.pending = true; this.submitButton.disabled = true; this.host.setAttribute('aria-busy', 'true'); this.statusNode.textContent = 'Submitting...'; await Promise.resolve(); this.pending = false; this.submitButton.disabled = false; this.host.removeAttribute('aria-busy'); this.emailInput.value = ''; this.statusNode.textContent = `${this.successMessage} (${email})`; } } ``` ```html

``` ## Host Property Inputs Use `@prop(...)` on a controller when the surrounding app code should pass real JS values through the attached host element instead of serializing them into attributes. ```ts import { RadiantController, controller, prop } from '@ecopages/radiant'; import { startControllers } from '@ecopages/radiant/controller-registry'; type ResultItem = { id: string; label: string; }; @controller('results-list') export class ResultsListController extends RadiantController { @prop({ type: Array, defaultValue: [] }) items!: ResultItem[]; override render() { return ( ); } } document.body.innerHTML = '
'; const host = document.querySelector('[data-controller="results-list"]') as HTMLElement & { items: ResultItem[]; }; host.items = [ { id: '1', label: 'Alpha' }, { id: '2', label: 'Beta' }, ]; startControllers(document); ``` `@prop(...)` on `RadiantController` uses a host property channel. It does not stringify objects into attributes, and it does not require JSON in markup. Prefer the focused registry subpath for `startControllers(...)` in setup modules like this one. ## DOM Access `RadiantController` exposes `getRef(...)` for `data-ref` lookups when the controller is enhancing HTML it does not render itself. - `@query(...)` creates reusable decorator-backed accessors for authored DOM inside the host - `getRef('panel')` returns the first matching element or `null` - `getRef('item', true)` returns all matching elements ## Reactive Surface Controllers expose the same binding helpers as element hosts: - `bind('name')` - `bindings.name` - `$.name` - `createReactiveField(...)` - `createReactiveMember(...)` - `registerReactiveMember(...)` - `getReactiveMember(...)` - `defineReactiveBinding(...)` Controllers also support `render()`, `requestUpdate()`, and `update()` when you want the controller to own the host element's inner DOM. **Removed in 0.3.0:** `trackReactiveRead(...)` and `registerReactiveDependencyReader(...)`. Use member `State` via `getReactiveMember(...)` or JSX bindings instead. Those lower-level helpers are useful for plain-JS integrations or abstraction layers. Prefer decorators first when the host shape is stable and readable. ## Derived Bindings Controllers expose the same derive API as element hosts. ### Member access in JSX For object-like bindings, member access is fine directly in `render()`: ```tsx render() { return

{this.$.config.label}

; } ``` The runtime memoizes each key, so this stays identity-stable across renders. ### `map` for transforms and lookups Use `map` for record lookups, transforms, and anything that is not a plain property read: ```tsx private readonly statusLabel = this.$.status.map((status) => STATUS_COPY[status]); render() { return

{this.statusLabel}

; } ``` Hoist `map` to a create-once host field. Do not call `.map(...)` inside `render()` — each call produces a new derived binding. Object props are shallow: projections update on whole-object replacement, not in-place nested mutation. ## Supported Decorators These decorators are part of the `RadiantController` authoring model today: - `@controller(...)` - `@attr(...)` - `@prop(...)` - `@query(...)` - `@state` - `@signal` - `@bindTo(...)` - `@provideContext(...)` - `@consumeContext(...)` - `@contextSelector(...)` - `@onContextUpdate(...)` - `@onUpdated(...)` - `@onEvent(...)` - `@bound` - `@debounce(...)` ## Element-Only Decorators These remain tied to `RadiantElement` because they depend on custom-element prop APIs or slot projection: - `@querySlot(...)` - `@event(...)` For render-owned controller markup, prefer normal JSX bindings and direct event handlers. For authored DOM inside controllers, copy 1:1 field values with `@bindTo(...)`. Use `@query(...)` when you need a live element handle, and `getRef(...)` for one-off lookups. ## Tooling And HMR Controller registration is intentionally conservative by default: - `@controller(...)` and `registerController(...)` keep the first registered class for an identifier - `replaceController(...)` is the explicit mutating path when tooling needs to swap the active class - `enableControllerReplacementForHmr()` is the convenience helper for browser shells that want decorator-driven replacement during HMR For the current docs-app integration and the planned `@ecopages/ecopages-jsx` plugin port, see [Controller Tooling & HMR](/docs/tools/controller-tooling-hmr). ## See Also - [@controller](/docs/decorators/controller) - [Controller Tooling & HMR](/docs/tools/controller-tooling-hmr) - [@attr](/docs/decorators/attr) - [@query](/docs/decorators/query) - [@bindTo](/docs/decorators/bind-to) - [Context](/docs/context/context) - [@onUpdated](/docs/decorators/on-updated) - [@onEvent](/docs/decorators/on-event) - [RadiantElement](/docs/components/radiant-element)