---
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 (
{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)