Radiant0.3.0-rc.12

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:

  1. Must contain a hyphen (-) to avoid conflicts with standard HTML elements.
  2. Must start with a lowercase letter.
  3. Cannot start with a hyphen.
  4. Cannot contain uppercase letters.
// Valid names
@customElement('user-card')
@customElement('my-app-button')
 
// Invalid names
@customElement('usercard')      // Missing hyphen
@customElement('User-Card')     // Contains uppercase

Recommendation: 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
  • @state for 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, and this.$ may read inside the component.
  • Internal-only state such as @state fields 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:

  • UserCardProps is the external API used by JSX consumers.
  • UserCardBindings includes both public props and internal reactive state.
  • isExpanded is available inside the component through this.$.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.isExpanded is used for the class expression because render logic needs the raw boolean.
  • this.$.name and this.$.isExpanded are 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.count or this.isExpanded are the normal choice when render logic needs the raw value.
  • Bindings such as this.$.count or this.bindings.count are 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 class or style composition.
  • Prefer bindings for leaf text, whole data-* or aria-* values, boolean attributes, and prop:* 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.