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. For the type each decorated field takes, see Typing Decorated Fields.
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.
import type { JSX_PROPS } from '@ecopages/jsx';
export type UserCardProps = {
name?: string;
avatarUrl?: string;
isAdmin?: boolean;
};
@customElement('user-card')
export class UserCard extends RadiantElement {
declare readonly [JSX_PROPS]?: UserCardProps;
@prop({ type: String }) name!: string;
@prop({ type: String }) avatarUrl!: string;
@prop({ type: Boolean }) isAdmin?: boolean;
}JSX Integration (Ecopages JSX)
With jsxImportSource: "@ecopages/jsx", declare the public props on the class
with JSX_PROPS, then use the class as the JSX element type.
/** @jsxImportSource @ecopages/jsx */
import { UserCard } from './user-card';
<UserCard name="Ada" />;If a view uses the string tag instead, add the class to HTMLElementTagNameMap:
declare global {
interface HTMLElementTagNameMap {
'user-card': UserCard;
}
}
<user-card name="Ada" />;At runtime, import the class's module so its @customElement(...) decorator
registers the element before rendering.
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 { JSX_PROPS } 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> {
declare readonly [JSX_PROPS]?: UserCardProps;
@prop({ type: String, defaultValue: 'Anonymous' }) name!: string;
@prop({ type: String, defaultValue: '' }) 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>
);
}
}
<UserCard name="Ada" />;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 this.slottedElements 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 | null;
@onUpdated('count')
syncCount() {
if (this.countEl) 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 })
@state
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.
Use autonomous custom elements
The @customElement decorator supports autonomous custom elements only. Its options type excludes extends, and a defined extends value passed at runtime throws a TypeError before registration. Use an autonomous element based on RadiantElement instead.