Best Practices
While Radiant is designed for flexibility, these patterns keep the docs, runtime, and type surfaces aligned with the current host model.
Prefer RadiantElement For Custom Elements
RadiantElement is the primary custom-element base class.
Use it in both of these cases:
- The host enhances authored light DOM and stays mostly imperative.
- The host overrides
render()and owns a JSX view directly.
Use RadiantController when the DOM is authored elsewhere and you only need controller behavior on top of that existing markup.
Naming Custom Elements
Custom element names must follow these browser-enforced rules:
- Must contain a hyphen (
-) to avoid conflicts with standard HTML elements. - Must start with a lowercase letter.
- Cannot start with a hyphen.
- Cannot contain uppercase letters.
// Valid names
@customElement('user-card')
@customElement('my-app-button')
// Invalid names
@customElement('usercard') // Missing hyphen
@customElement('User-Card') // Contains uppercaseRecommendation: Namespace Your Components
To avoid tag name collisions, especially if you're building a library, use a consistent prefix.
@customElement('acme-button')
@customElement('acme-card')Component Design
One Component Per File
For better organization and ease of navigation, define only one custom element per file.
Export Your Class
Always export the component class. This is crucial for:
- Testing the component in isolation.
- Type checking when using the component in other TypeScript files.
Use Descriptive Names
Use clear, descriptive names. Avoid generic names like my-component or custom-element.
Prefer @prop And @state Names
Use the semantic names in application code:
@prop(...)for public custom-element API@statefor internal mutable state
Typing Components
Type safety is a core feature of Radiant. We recommend following these patterns to ensure your components are correctly typed for both TypeScript and JSX integration.
Defining Props
Define a separate type for your component's public properties. This type can be shared between the component script and the JSX template.
export type UserCardProps = {
name?: string;
avatarUrl?: string;
isAdmin?: boolean;
};
@customElement('user-card')
export class UserCard extends RadiantElement {
@prop({ type: String }) declare name: string;
@prop({ type: String }) declare avatarUrl: string;
@prop({ type: Boolean }) declare isAdmin: boolean;
}JSX Integration (Ecopages JSX)
To enable type checking for your custom element in JSX templates with
jsxImportSource: "@ecopages/jsx", augment the Ecopages JSX runtime module.
import type { JsxCustomElementAttributes } from '@ecopages/jsx';
declare module '@ecopages/jsx/jsx-runtime' {
interface JsxCustomIntrinsicElements {
'user-card': JsxCustomElementAttributes<HTMLElement, UserCardProps>;
}
}Public Props vs Internal Bindings
For RadiantElement, keep the public JSX attribute type
separate from the internal reactive binding shape when those two surfaces are
not the same.
- Public props describe what consumers may pass from JSX.
- Internal bindings describe what
this.bind(...),this.bindings, andthis.$may read inside the component. - Internal-only state such as
@statefields should usually stay out of the public JSX contract.
/** @jsxImportSource @ecopages/jsx */
import type { JsxCustomElementAttributes } from '@ecopages/jsx';
import { RadiantElement, customElement, prop, state } from '@ecopages/radiant';
export type UserCardProps = {
name?: string;
avatarUrl?: string;
};
type UserCardBindings = UserCardProps & {
isExpanded: boolean;
};
@customElement('user-card')
export class UserCard extends RadiantElement<UserCardBindings> {
@prop({ type: String, defaultValue: 'Anonymous' }) declare name: string;
@prop({ type: String, defaultValue: '' }) declare avatarUrl: string;
@state isExpanded = false;
toggleExpanded = () => {
this.isExpanded = !this.isExpanded;
};
override render() {
return (
<article class={this.isExpanded ? 'user-card user-card--expanded' : 'user-card'}>
<h2>{this.$.name}</h2>
{this.avatarUrl && <img src={this.avatarUrl} alt="" />}
<button type="button" on:click={this.toggleExpanded}>
{this.$.isExpanded ? 'Hide details' : 'Show details'}
</button>
</article>
);
}
}
declare module '@ecopages/jsx/jsx-runtime' {
interface JsxCustomIntrinsicElements {
'user-card': JsxCustomElementAttributes<HTMLElement, UserCardProps>;
}
}In this pattern:
UserCardPropsis the external API used by JSX consumers.UserCardBindingsincludes both public props and internal reactive state.isExpandedis available inside the component throughthis.$.isExpanded, but consumers cannot pass<user-card isExpanded />unless you intentionally add that field to the public props type.
Notice the split inside render():
this.isExpandedis used for theclassexpression because render logic needs the raw boolean.this.$.nameandthis.$.isExpandedare used for stable text positions where fine-grained patching is desirable.
If the public props and internal reactive members are intentionally the same surface, reusing one shared type is fine. Split them only when the external API should be narrower or more stable than the internal state.
Plain Reads vs Bindings
Use both, but use them for different jobs.
- Plain reads such as
this.countorthis.isExpandedare the normal choice when render logic needs the raw value. - Bindings such as
this.$.countorthis.bindings.countare the better choice when JSX should patch a stable child or attribute position directly.
Practical rule of thumb:
- Prefer plain reads for branching, comparisons, list shape, slot fallback, and
classorstylecomposition. - Prefer bindings for leaf text, whole
data-*oraria-*values, boolean attributes, andprop:*bindings.
<article
class={this.isExpanded ? 'user-card user-card--expanded' : 'user-card'}
data={{ state: this.$.isExpanded }}
>
<h2>{this.$.name}</h2>
</article>In that example, the class expression still belongs to the host render pass, while the bound data-state and heading text can patch in place.
@query vs paint
@query and @querySlot are live element handles — slotted/consumer-authored content, a RadiantController enhancing existing markup, or a node needed by a third-party imperative API. They are not a substitute for copying a reactive field into a node.
- Host owns
render(): bind the value in JSX ({this.$.count}). - Host keeps authored light DOM: copy the field with
@bindTo.
// Avoid
@query({ ref: 'count' }) countEl!: HTMLElement;
@onUpdated('count')
syncCount() {
this.countEl.textContent = String(this.count);
}
// Prefer — host-owned JSX
override render() {
return <span>{this.$.count}</span>;
}
// Prefer — authored light DOM
@bindTo({ ref: 'count', text: true })
count = 0;Derived Bindings
Use derived bindings when JSX needs a projection of a reactive value — not the raw binding itself.
Member access — use inline in JSX
For object keys, write member access directly in render():
render() {
return <p>{this.$.config.label}</p>;
}This is equivalent to this.$.config.map((config) => config.label), but the runtime caches each key on the binding. You do not need a field initializer for simple reads like this.
map — hoist to a create-once field
Use map for record lookups, transforms, and non-property projections:
private readonly themeLabel = this.$.preference.map((preference) => THEME_CONFIG[preference].label);
render() {
return <p>{this.themeLabel}</p>;
}Do not call .map(...) inside render(). Each call creates a new derived binding and defeats fine-grained patching.
Anti-patterns
// Wrong — subscribable object used as a record key
THEME_CONFIG[this.$.preference]
// Wrong — new derived binding every render
render() {
return <p>{this.$.preference.map((p) => THEME_CONFIG[p].label)}</p>;
}
// Right — simple object key inline
render() {
return <p>{this.$.config.label}</p>;
}
// Right — transform hoisted once
private readonly themeLabel = this.$.preference.map((p) => THEME_CONFIG[p].label);Object props are shallow: map and member access track whole-object replacement, not in-place nested mutation.
If a component never uses this.$, this.bindings, or this.bind(...), you usually do not need a separate Bindings type at all.
Technical Tips
Registration Timing
Custom elements are registered immediately when the file is evaluated. If you import a file containing a @customElement decorator, the element is ready for use in the DOM.
Reactive State Mutation (Reference Equality)
Radiant uses reference equality to detect changes in reactive props and state fields.
// Won't trigger update (same reference)
this.items.push('new item');
//Triggers update (new reference)
this.items = [...this.items, 'new item'];Keep Public Props Narrow
For RadiantElement<Bindings>, treat the generic binding shape as an internal reactive surface. When the public JSX API should be smaller, use a dedicated Props type and keep internal bindings separate.
Prefer autonomous elements over customized built-ins
While Radiant supports extending built-in elements (e.g., extends: 'button'), browser support for customized built-in elements is limited.