Radiant0.3.0-rc.2

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.

import { RadiantController, controller } from '@ecopages/radiant';
import { startControllers } from '@ecopages/radiant/controller-registry';
 
@controller('search')
class SearchController extends RadiantController {
	connect() {
		super.connect();
	}
}
 
startControllers(document);
<section data-controller="search"></section>

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.

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

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 (
			<section>
				<button type="button" on:click={this.toggle}>
					{this.open ? 'Hide' : 'Show'} details
				</button>
					<div hidden={!this.open} data={{ toggleCount: this.toggles }}>
					Details
				</div>
			</section>
		);
	}
}
<section data-controller="disclosure" data-open="false">
	<button type="button">Toggle</button>
	<div hidden>Details</div>
</section>

Authored Form Example

When the host markup already exists, let the controller read stable refs with @query(...) and bind behavior with @onEvent(...).

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})`;
	}
}
<section data-controller="newsletter-form" data-success-message="Subscription saved.">
	<form data-ref="form">
		<label>
			Email
			<input data-ref="email" type="email" name="email" />
		</label>
		<button data-ref="submit" type="submit">Subscribe</button>
		<p data-ref="status" aria-live="polite"></p>
	</form>
</section>

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.

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 (
			<ul>
				{this.items.map((item) => (
					<li key={item.id}>{item.label}</li>
				))}
			</ul>
		);
	}
}
 
document.body.innerHTML = '<section data-controller="results-list"></section>';
 
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():

render() {
  return <p>{this.$.config.label}</p>;
}

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:

private readonly statusLabel = this.$.status.map((status) => STATUS_COPY[status]);
 
render() {
  return <p>{this.statusLabel}</p>;
}

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
  • @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, prefer @query(...) for stable refs 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.

See Also