Component Composition Patterns
apps/docs/src/content/docs/components/advanced/composition-patterns Click to copy apps/docs/src/content/docs/components/advanced/composition-patterns Web components enable powerful composition patterns that promote reusability, maintainability, and flexibility. This guide explores proven patterns used throughout hx-library for building complex, coordinated component hierarchies while maintaining encapsulation and accessibility.
Reading note: Several samples below describe APIs that diverge from the shipped contract. Highest-impact mismatches:
hx-radiois a presentational child ofhx-radio-group; it isn’t a standalone form control. Usehx-radio-groupas the form participant; the group’shx-changedetail includes the selectedvalueandcheckedstate.hx-alertusesdismissible(notdismissible) and only renders the icon slot whenshow-iconis also set.hx-icon-buttondoes not extendHelixButtonand has noiconproperty — it’s a standalone host that acceptslibrary/namelikehx-icon. Don’t try to inherit fromHelixButtonto compose it.hx-containeruseswidth(notsize), and form controls usehx-size(not nativesize).- There is no
org-theme-provider/org-themed-card/org-icon-form-inputin the library — those samples are consumer-owned patterns; rename them with anorg-prefix in your own code.hx-formrequires a native submit event from its underlying<form>(withaction="") to dispatch itshx-submitevent — wiring anhx-button type="submit"inside anhx-formis the canonical pattern, but the dispatch path runs through the form element, not directly off the button.- Use the per-component CEM (
packages/hx-library/custom-elements.json) as the canonical API source when in doubt.
What is Composition?
Section titled “What is Composition?”Composition is the practice of building complex components from simpler building blocks. Unlike inheritance (extending class behavior), composition combines independent components through slots, events, and coordinated state management.
Benefits:
- Flexibility — Mix and match components without rigid hierarchies
- Testability — Test each component in isolation
- Maintainability — Change one component without cascading failures
- Reusability — Combine components in ways the original author never anticipated
Core principle: Favor composition over inheritance. Build small, focused components that work together rather than large, monolithic components that try to do everything.
1. Compound Components Pattern
Section titled “1. Compound Components Pattern”Compound components are a set of components designed to work together as a cohesive unit. The parent component coordinates behavior while child components remain independent and reusable.
Real Example: hx-radio-group + hx-radio
Section titled “Real Example: hx-radio-group + hx-radio”The radio group pattern demonstrates perfect compound component design:
<hx-radio-group label="Notification Preference" value="email"> <hx-radio value="email">Email notifications</hx-radio> <hx-radio value="sms">SMS notifications</hx-radio> <hx-radio value="push">Push notifications</hx-radio></hx-radio-group>How it works:
- Parent manages state —
hx-radio-groupowns the selected value - Children remain independent — Each
hx-radiocan be used standalone - Coordination via events — Radio buttons dispatch
hx-radio-selectevents that bubble to the group - Parent updates children — Group syncs checked state and tabindex across radios
// In hx-radio-group.tsprivate _handleRadioSelect = (e: CustomEvent<{ value: string }>): void => { e.stopPropagation();
const newValue = e.detail.value; if (newValue === this.value) return;
this.value = newValue; this._internals.setFormValue(this.value); this._syncRadios(); // Update all child radios
this.dispatchEvent( new CustomEvent('hx-change', { bubbles: true, composed: true, detail: { value: this.value }, }) );};
private _syncRadios(): void { const radios = this._getRadios();
radios.forEach((radio) => { radio.checked = radio.value === this.value && this.value !== ''; if (this.disabled) { radio.disabled = true; } });
// Roving tabindex for keyboard navigation const checkedRadio = radios.find((r) => r.checked); radios.forEach((radio) => { radio.tabIndex = -1; }); if (checkedRadio) { checkedRadio.tabIndex = 0; }}Key characteristics:
- Parent component queries children via
querySelectorAll('hx-radio') - Children dispatch custom events that parent listens for
- Parent syncs state to children on value changes
- Children remain functional when used standalone
- Keyboard navigation managed at parent level
When to Use Compound Components
Section titled “When to Use Compound Components”Use compound components when:
- Multiple components must coordinate behavior (select dropdown + options, accordion + panels)
- State management centralizes in a parent coordinator
- Children should remain independently reusable
- Keyboard navigation spans multiple child elements
Anti-pattern: Don’t tightly couple children to parents. Children should work standalone and not assume parent presence.
2. Container/Presentational Separation
Section titled “2. Container/Presentational Separation”Separate components into two categories: containers (manage state and logic) and presentational (render UI based on props).
Example: Smart Form Container + Dumb Field Components
Section titled “Example: Smart Form Container + Dumb Field Components”// Container: hx-form (manages validation, submission, state)@customElement('hx-form')export class HelixForm extends LitElement { checkValidity(): boolean { const formElements = this._getAllValidatableElements(); return formElements.every((el) => { if ('checkValidity' in el && typeof el.checkValidity === 'function') { return (el as HTMLInputElement).checkValidity(); } return true; }); }
getFormData(): FormData { const formData = new FormData(); const elements = this.getNativeFormElements(); for (const el of elements) { const input = el as HTMLInputElement; if (!input.name) continue;
if (input.type === 'checkbox' || input.type === 'radio') { if (input.checked) { formData.append(input.name, input.value || 'on'); } } else { formData.append(input.name, input.value); } } return formData; }}<!-- Presentational: hx-text-input (renders UI, no business logic) --><hx-form> <hx-text-input label="Patient ID" name="patient_id" required></hx-text-input>
<hx-text-input label="Email" name="email" type="email" required></hx-text-input>
<button type="submit">Submit</button></hx-form>Benefits:
- Container handles complex logic (validation, submission, error handling)
- Presentational components remain simple and testable
- Swap presentational components without touching container logic
- Container can be tested with mock/stub child components
Implementation Pattern
Section titled “Implementation Pattern”Container responsibilities:
- Query child components via
querySelectorAll - Aggregate state from multiple children
- Coordinate behavior across children
- Handle business logic and side effects
Presentational responsibilities:
- Accept props/attributes as inputs
- Dispatch events for user interactions
- Render UI based on current state
- No side effects or external dependencies
3. Slot Forwarding Pattern
Section titled “3. Slot Forwarding Pattern”Slot forwarding passes slotted content from a parent component through to a child component, enabling flexible content projection across component boundaries.
Example: Field Wrapper Forwarding Slots to Label/Error
Section titled “Example: Field Wrapper Forwarding Slots to Label/Error”// hx-text-input forwards label slot to internal structureoverride render() { return html` <div part="field"> <!-- Label slot: consumer can provide custom label markup --> <slot name="label" @slotchange=${this._handleLabelSlotChange}> ${this.label ? html` <label part="label" for=${this._inputId}> ${this.label} ${this.required ? html`<span aria-hidden="true">*</span>` : nothing} </label> ` : nothing} </slot>
<div part="input-wrapper"> <slot name="prefix"></slot> <input id=${this._inputId} /> <slot name="suffix"></slot> </div>
<!-- Error slot: Drupal can inject its own error markup --> <slot name="error" @slotchange=${this._handleErrorSlotChange}> ${this.error ? html`<div role="alert">${this.error}</div>` : nothing} </slot> </div> `;}Usage in Drupal:
<hx-text-input name="email" required> <label slot="label" class="form-label"> Email Address <span class="required-indicator">*</span> </label>
<div slot="error" class="form-error" role="alert">Please enter a valid email address.</div></hx-text-input>Slot change detection:
private _handleErrorSlotChange(e: Event): void { const slot = e.target as HTMLSlotElement; this._hasErrorSlot = slot.assignedNodes({ flatten: true }).length > 0; this.requestUpdate(); // Re-render to reflect slot presence}Benefits:
- Consumer controls markup structure (critical for Drupal Form API compatibility)
- Component provides sensible defaults via fallback content
- Styling remains isolated to Shadow DOM
- Accessibility attributes remain component-managed
Multi-Level Slot Forwarding
Section titled “Multi-Level Slot Forwarding”Complex components can forward slots through multiple layers:
<!-- Outer wrapper --><hx-card> <div slot="heading"> <hx-badge variant="success">Active</hx-badge> Patient Record #12345 </div>
<p>Patient details go here...</p>
<div slot="actions"> <hx-button variant="primary">Edit</hx-button> <hx-button variant="ghost">Archive</hx-button> </div></hx-card>override render() { return html` <div part="card"> <div part="heading" ?hidden=${!this._hasSlotContent['heading']}> <slot name="heading" @slotchange=${this._handleSlotChange('heading')}></slot> </div>
<div part="body"> <slot></slot> </div>
<div part="actions" ?hidden=${!this._hasSlotContent['actions']}> <slot name="actions" @slotchange=${this._handleSlotChange('actions')}></slot> </div> </div> `;}
private _handleSlotChange(slotName: string) { return (e: Event) => { const slot = e.target as HTMLSlotElement; this._hasSlotContent[slotName] = slot.assignedNodes({ flatten: true }).length > 0; this.requestUpdate(); };}4. Render Props Equivalent (Slots with Templates)
Section titled “4. Render Props Equivalent (Slots with Templates)”Web components use slots where React uses render props. This pattern allows consumers to inject custom rendering logic while maintaining component coordination.
Example: Custom Option Rendering in Select
Section titled “Example: Custom Option Rendering in Select”<hx-select label="Medication" value="aspirin"> <option value="aspirin">Aspirin (100mg)</option> <option value="ibuprofen">Ibuprofen (200mg)</option> <option value="acetaminophen">Acetaminophen (500mg)</option></hx-select>The select component clones slotted options into its internal native <select>:
private _syncOptions(): void { const slot = this.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])'); const slottedOptions = slot ?.assignedElements({ flatten: true }) .filter((el): el is HTMLOptionElement => el instanceof HTMLOptionElement);
// Remove previously cloned options const existingCloned = this._select.querySelectorAll('option[data-cloned]'); existingCloned.forEach((opt) => opt.remove());
// Clone slotted options into the native select slottedOptions?.forEach((option) => { const clone = option.cloneNode(true) as HTMLOptionElement; clone.setAttribute('data-cloned', ''); this._select.appendChild(clone); });}Benefits:
- Consumer controls option markup and content
- Component maintains form participation and accessibility
- Works seamlessly with Drupal’s Form API option rendering
Template-Based Slots
Section titled “Template-Based Slots”For more complex rendering scenarios, consumers can provide full templates:
<hx-alert variant="warning" dismissible> <div slot="icon"> <!-- Custom SVG icon --> <svg>...</svg> </div>
<strong>System Maintenance Scheduled</strong> <p>Downtime expected: Feb 20, 2026 at 02:00 UTC</p>
<div slot="actions"> <hx-button variant="ghost" size="sm">Learn More</hx-button> </div></hx-alert>5. Higher-Order Component Pattern (Mixins)
Section titled “5. Higher-Order Component Pattern (Mixins)”TypeScript mixins enable higher-order component patterns by composing class behaviors without deep inheritance hierarchies.
Example: Form-Associated Behavior Mixin
Section titled “Example: Form-Associated Behavior Mixin”// Form participation mixin (hypothetical)type Constructor<T = {}> = new (...args: any[]) => T;
export function FormAssociatedMixin<TBase extends Constructor<LitElement>>(Base: TBase) { return class FormAssociated extends Base { static formAssociated = true;
private _internals!: ElementInternals;
constructor(...args: any[]) { super(...args); this._internals = this.attachInternals(); }
get form(): HTMLFormElement | null { return this._internals.form; }
get validity(): ValidityState { return this._internals.validity; }
checkValidity(): boolean { return this._internals.checkValidity(); }
reportValidity(): boolean { return this._internals.reportValidity(); }
formResetCallback(): void { // Override in subclass } };}
// Usage@customElement('hx-custom-input')export class CustomInput extends FormAssociatedMixin(LitElement) { @property({ type: String }) value = '';
override formResetCallback(): void { this.value = ''; this._internals.setFormValue(''); }}Benefits:
- Share behavior across multiple components without inheritance
- Compose multiple mixins for complex behavior
- Type-safe with proper TypeScript generics
- Test mixins independently
Real-world use case: All form components (hx-text-input, hx-select, hx-checkbox, hx-switch, hx-textarea, hx-radio-group) implement ElementInternals API with nearly identical boilerplate. A mixin reduces duplication.
6. Provider/Consumer Pattern (Context)
Section titled “6. Provider/Consumer Pattern (Context)”The provider/consumer pattern shares state across distant components without prop drilling. Web components use custom events for this pattern.
Example: Theme Provider
Section titled “Example: Theme Provider”// Theme provider component@customElement('org-theme-provider')export class ThemeProvider extends LitElement { @property({ type: String }) theme: 'light' | 'dark' = 'light';
override connectedCallback(): void { super.connectedCallback(); this._broadcastTheme(); }
override updated(changedProperties: Map<string, unknown>): void { super.updated(changedProperties); if (changedProperties.has('theme')) { this._broadcastTheme(); } }
private _broadcastTheme(): void { // Dispatch non-bubbling event to all descendants const event = new CustomEvent('hx-theme-change', { detail: { theme: this.theme }, bubbles: false, composed: false, });
// Notify all children this.querySelectorAll('*').forEach((child) => { child.dispatchEvent(event); }); }
override render() { return html` <div class="theme-provider" data-theme=${this.theme}> <slot></slot> </div> `; }}// Consumer component@customElement('org-themed-card')export class ThemedCard extends LitElement { @state() private _theme: 'light' | 'dark' = 'light';
override connectedCallback(): void { super.connectedCallback(); this.addEventListener('hx-theme-change', this._handleThemeChange as EventListener); }
override disconnectedCallback(): void { super.disconnectedCallback(); this.removeEventListener('hx-theme-change', this._handleThemeChange as EventListener); }
private _handleThemeChange = (e: CustomEvent<{ theme: 'light' | 'dark' }>): void => { this._theme = e.detail.theme; };
override render() { return html` <div class="card card--${this._theme}"> <slot></slot> </div> `; }}Usage:
<org-theme-provider theme="dark"> <org-themed-card>This card is dark themed</org-themed-card> <org-themed-card>So is this one</org-themed-card></org-theme-provider>Alternative: CSS Custom Properties
For styling-only context, CSS custom properties are simpler:
@customElement('org-theme-provider')export class ThemeProvider extends LitElement { @property({ type: String }) theme: 'light' | 'dark' = 'light';
override render() { return html` <div class="theme-provider" style=" --hx-theme-bg: ${this.theme === 'dark' ? '#1a1a1a' : '#ffffff'}; --hx-theme-color: ${this.theme === 'dark' ? '#ffffff' : '#000000'}; " > <slot></slot> </div> `; }}Children inherit CSS custom properties automatically:
/* In hx-card.styles.ts */:host { background: var(--hx-theme-bg, var(--hx-card-bg)); color: var(--hx-theme-color, var(--hx-card-color));}7. Controlled vs Uncontrolled Components
Section titled “7. Controlled vs Uncontrolled Components”Controlled components derive their state from props/attributes (single source of truth external). Uncontrolled components manage their own internal state.
Controlled Example: hx-text-input
Section titled “Controlled Example: hx-text-input”<!-- Value controlled by parent component --><hx-text-input id="email-input" label="Email" .value="${this.emailValue}" @hx-input="${this._handleEmailInput}"></hx-text-input>// Parent controls stateprivate _handleEmailInput(e: CustomEvent<{ value: string }>): void { this.emailValue = e.detail.value; // Validate, transform, or sync with other state}Characteristics:
- Parent component owns state
- Component dispatches events on user interaction
- Parent updates component’s value prop
- Two-way data flow: user interaction → event → parent update → prop change
Uncontrolled Example: hx-alert
Section titled “Uncontrolled Example: hx-alert”<!-- Component manages its own open/closed state --><hx-alert variant="info" dismissible open> This alert manages its own visibility. </hx-alert>// Component manages internal state@property({ type: Boolean, reflect: true })open = true;
private _handleClose(): void { this.open = false; // Component updates its own state
this.dispatchEvent( new CustomEvent('hx-close', { bubbles: true, composed: true, detail: { reason: 'user' }, }) );}Characteristics:
- Component owns state
- Parent can set initial value via attribute
- Component updates its own state directly
- Parent observes changes via events (optional)
Hybrid: Controlled with Uncontrolled Fallback
Section titled “Hybrid: Controlled with Uncontrolled Fallback”Most form components support both modes:
@customElement('hx-text-input')export class HelixTextInput extends LitElement { @property({ type: String }) value = '';
private _handleInput(e: Event): void { const target = e.target as HTMLInputElement;
// Update internal state (uncontrolled) this.value = target.value;
// Dispatch event (allows controlled mode) this.dispatchEvent( new CustomEvent('hx-input', { detail: { value: this.value }, }), ); }}Usage patterns:
<!-- Uncontrolled: component manages state --><hx-text-input label="Name" name="name"></hx-text-input>
<!-- Controlled: parent manages state --><hx-text-input label="Name" .value="${this.name}" @hx-input="${(e)" =""> this.name = e.detail.value} ></hx-text-input>Decision Matrix
Section titled “Decision Matrix”| Pattern | When to Use | Example |
|---|---|---|
| Controlled | Complex validation, multi-field coordination, sync with external state | Form wizard, dependent fields |
| Uncontrolled | Simple forms, independent fields, minimal parent involvement | Newsletter signup, search input |
| Hybrid | General-purpose components that support both modes | All hx-library form inputs |
8. Composition vs Inheritance
Section titled “8. Composition vs Inheritance”Web components encourage composition over inheritance, but inheritance has valid uses.
When to Use Inheritance
Section titled “When to Use Inheritance”Extend a base component when:
- Changing behavior without changing structure
- Adding new properties/methods to existing component
- Creating specialized variants of existing component
// Valid: Extend existing component for specialization@customElement('hx-icon-button')export class IconButton extends HelixButton { @property({ type: String }) icon = '';
override render() { return html` <button part="button"> <svg class="icon">${this._renderIcon()}</svg> <span class="sr-only"><slot></slot></span> </button> `; }}When to Use Composition
Section titled “When to Use Composition”Compose multiple components when:
- Combining distinct behaviors
- Building complex UI from simpler pieces
- Maintaining independent reusability
<!-- Preferred: Compose existing components --><hx-button variant="primary"> <svg slot="prefix" class="icon">...</svg> Save Changes</hx-button>Anti-pattern: Deep inheritance hierarchies.
// Avoid: Fragile inheritance chainclass BaseInput extends LitElement {}class ValidatedInput extends BaseInput {}class FormInput extends ValidatedInput {}class IconFormInput extends FormInput {} // Too deep!Better: Composition with mixins.
// Preferred: Flat hierarchy with composed behaviors@customElement('org-icon-form-input')export class IconFormInput extends FormAssociatedMixin(LitElement) { render() { return html` <hx-text-input> <svg slot="prefix">${this.icon}</svg> </hx-text-input> `; }}9. Real-World Composition Examples
Section titled “9. Real-World Composition Examples”Example 1: Patient Alert Dashboard
Section titled “Example 1: Patient Alert Dashboard”Compose multiple components for complex healthcare UI:
<hx-container size="lg"> <hx-card variant="featured"> <h2 slot="heading">Active Patient Alerts</h2>
<hx-alert variant="error" dismissible open> <strong>Critical: Allergy Alert</strong> <p>Patient has documented penicillin allergy.</p> <hx-button slot="actions" variant="primary" size="sm"> Review History </hx-button> </hx-alert>
<hx-alert variant="warning" dismissible open> <strong>Lab Results Pending</strong> <p>Blood work results expected within 2 hours.</p> </hx-alert>
<hx-alert variant="info"> <strong>Medication Schedule</strong> <p>Next dose due at 14:00 UTC.</p> </hx-alert>
<div slot="actions"> <hx-button variant="primary">Acknowledge All</hx-button> <hx-button variant="ghost">Export Report</hx-button> </div> </hx-card></hx-container>Composition benefits:
- Each component tested independently
- Alerts can be added/removed dynamically
- Card provides visual structure without coupling to alert logic
- Container manages responsive layout
- All components remain reusable
Example 2: Multi-Step Form with Validation
Section titled “Example 2: Multi-Step Form with Validation”<hx-form id="patient-intake"> <hx-radio-group label="Visit Type" name="visit_type" required value="new-patient"> <hx-radio value="new-patient">New Patient</hx-radio> <hx-radio value="follow-up">Follow-Up</hx-radio> <hx-radio value="emergency">Emergency</hx-radio> </hx-radio-group>
<hx-text-input label="Patient ID" name="patient_id" required pattern="[A-Z]{2}[0-9]{6}" ></hx-text-input>
<hx-select label="Primary Physician" name="physician" required> <option value="">Select physician...</option> <option value="dr-smith">Dr. Smith</option> <option value="dr-jones">Dr. Jones</option> </hx-select>
<hx-textarea label="Reason for Visit" name="reason" required rows="4"></hx-textarea>
<hx-checkbox name="consent" required> I consent to treatment and data processing. </hx-checkbox>
<hx-button type="submit" variant="primary"> Submit Intake Form </hx-button></hx-form>
<script> const form = document.getElementById('patient-intake');
form.addEventListener('hx-submit', (e) => { const { valid, values } = e.detail; console.log('Form submitted:', values); });
form.addEventListener('hx-invalid', (e) => { const { errors } = e.detail; console.error('Validation failed:', errors); });</script>Composition benefits:
- Form coordinates validation across all fields
- Each input manages its own UI and ElementInternals
- Form collects data without knowing field implementation details
- Fields can be reordered or removed without breaking form logic
10. Best Practices
Section titled “10. Best Practices”Do: Keep Components Focused
Section titled “Do: Keep Components Focused”Each component should have a single, clear responsibility.
<!-- Good: Each component has one job --><hx-card> <hx-badge slot="heading" variant="success">Active</hx-badge> <hx-prose> <p>Patient discharge summary...</p> </hx-prose></hx-card>Do: Use Custom Events for Coordination
Section titled “Do: Use Custom Events for Coordination”Components communicate through well-defined custom events.
// Component dispatches semantic eventthis.dispatchEvent( new CustomEvent('hx-change', { bubbles: true, composed: true, detail: { value: this.value }, }),);Do: Provide Slot Fallbacks
Section titled “Do: Provide Slot Fallbacks”Always provide sensible defaults when slots are empty.
render() { return html` <slot name="icon"> ${this._renderDefaultIcon()} <!-- Fallback --> </slot> `;}Do: Detect Slot Content
Section titled “Do: Detect Slot Content”Track slot presence to conditionally render wrappers.
private _handleSlotChange(e: Event): void { const slot = e.target as HTMLSlotElement; this._hasContent = slot.assignedNodes({ flatten: true }).length > 0; this.requestUpdate();}
render() { return html` <div ?hidden=${!this._hasContent}> <slot @slotchange=${this._handleSlotChange}></slot> </div> `;}Don’t: Tightly Couple Components
Section titled “Don’t: Tightly Couple Components”Avoid direct references or assumptions about component structure.
// Bad: Assumes parent structureconst parent = this.closest('hx-form');parent.value = 'bad'; // Fragile!
// Good: Dispatch eventthis.dispatchEvent( new CustomEvent('hx-input', { bubbles: true, detail: { value: this.value }, }),);Don’t: Overuse Inheritance
Section titled “Don’t: Overuse Inheritance”Extend components sparingly. Prefer composition.
// Bad: Deep inheritance for behavior reuseclass MyInput extends HelixTextInput { // Brittle!}
// Good: Compose existing componentsrender() { return html` <hx-text-input label=${this.label} .value=${this.value} @hx-input=${this._handleInput} ></hx-text-input> `;}Don’t: Manipulate Slotted Content
Section titled “Don’t: Manipulate Slotted Content”Let slotted content remain under consumer control.
// Bad: Mutating slotted contentconst slot = this.shadowRoot.querySelector('slot');const nodes = slot.assignedElements();nodes[0].classList.add('modified'); // Don't do this!
// Good: Style via CSS parts or custom propertiesrender() { return html` <slot></slot> `;}Summary
Section titled “Summary”Composition patterns enable flexible, maintainable component architectures:
- Compound Components — Parent coordinates children via events and shared state
- Container/Presentational — Separate business logic from UI rendering
- Slot Forwarding — Project content through component boundaries
- Render Props Equivalent — Use slots for consumer-controlled rendering
- Mixins — Share behavior across components without inheritance
- Provider/Consumer — Share context across distant descendants
- Controlled/Uncontrolled — Support both parent-managed and self-managed state
- Composition over Inheritance — Build complex UIs from simple, focused components
These patterns appear throughout hx-library, enabling enterprise-grade healthcare applications while maintaining testability, accessibility, and developer experience.
Related:
- Form Patterns — composition patterns for form-specific HELiX components
- Custom Events in HELiX components — the canonical event contract for cross-component coordination