--- title: "Best Practices" description: "General best practices and recommendations for building components with Radiant." group: Getting Started order: 3 --- import { RuiAlert, RuiAlertDescription, RuiAlertTitle } from '@ecopages/radiant-ui/alert'; # 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**. ```typescript // 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. ```typescript @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. ```typescript 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. ```typescript import type { JsxCustomElementAttributes } from '@ecopages/jsx'; declare module '@ecopages/jsx/jsx-runtime' { interface JsxCustomIntrinsicElements { 'user-card': JsxCustomElementAttributes; } } ``` ### 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. ```tsx /** @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 { @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 (

{this.$.name}

{this.avatarUrl && }
); } } declare module '@ecopages/jsx/jsx-runtime' { interface JsxCustomIntrinsicElements { 'user-card': JsxCustomElementAttributes; } } ``` 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 `` 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. ```tsx

{this.$.name}

``` 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`. ```tsx // Avoid @query({ ref: 'count' }) countEl!: HTMLElement; @onUpdated('count') syncCount() { this.countEl.textContent = String(this.count); } // Prefer — host-owned JSX override render() { return {this.$.count}; } // 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()`: ```tsx render() { return

{this.$.config.label}

; } ``` 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: ```tsx private readonly themeLabel = this.$.preference.map((preference) => THEME_CONFIG[preference].label); render() { return

{this.themeLabel}

; } ``` Do **not** call `.map(...)` inside `render()`. Each call creates a new derived binding and defeats fine-grained patching. #### Anti-patterns ```tsx // Wrong — subscribable object used as a record key THEME_CONFIG[this.$.preference] // Wrong — new derived binding every render render() { return

{this.$.preference.map((p) => THEME_CONFIG[p].label)}

; } // Right — simple object key inline render() { return

{this.$.config.label}

; } // 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. IMPORTANT

For complex types like Object or Array, in-place mutations (like push or splice) will not trigger a re-render. You must always reassign the property to a new reference.

```typescript // Won't trigger update (same reference) this.items.push('new item'); //Triggers update (new reference) this.items = [...this.items, 'new item']; ``` NOTE

This behavior is intentional for performance and is verified by our core test suite.

### Keep Public Props Narrow For `RadiantElement`, 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. WARNING

Customized built-in elements have limited browser support and are not supported in Safari. For maximum compatibility, we recommend creating autonomous custom elements.