Skip to content

Components: properties, events, and lifecycle ​

This guide starts with the component boundary instead of the TypeScript type list. It explains what data crosses that boundary, who owns it, and which Gluon API to use. All examples import public 1.13.0 package entry points and are compiled by the documentation quality gate.

The four terms to know ​

  • A property is an input stored on the Custom Element instance. A property can hold any JavaScript value, including an object or array.
  • An attribute is HTML text. A declaration can convert an attribute to a property and can optionally reflect a property back to the attribute.
  • An event is native output from the component. Its application payload is in CustomEvent.detail.
  • A component class is the Custom Element implementation. Its static fields describe the public contract; its instance owns rendering and lifecycle.

Props are inputs and events are outputs. Do not mutate an object owned by the parent and call that an event. Emit the requested change and let the owner decide whether to update the property.

Build one stateful component first ​

Before comparing all authoring models, build the complete common path once:

ts
import {
  css,
  defineGluonElement,
  elementEvent,
  html,
} from '@gluonjs/core';

export const QuantityChanged = elementEvent<{ readonly quantity: number }>({
  bubbles: true,
});

export const QuantityControl = defineGluonElement({
  tagName: 'quantity-control',
  properties: {
    label: { type: String, reflect: true, default: 'Quantity' },
  },
  events: { change: QuantityChanged },
  styles: css`
    :host { display: block; }
    button { min-block-size: 44px; font: inherit; }
  `,
  setup(context) {
    const quantity = context.state('quantity', 1);
    const controller = new AbortController();

    context.onConnected(() => {
      context.host.addEventListener('keydown', (event) => {
        if (event.key === 'ArrowUp') quantity.value += 1;
      }, { signal: controller.signal });
      context.host.shadowRoot?.querySelector('button')?.focus({ preventScroll: true });
    });
    context.onUpdated(() => {
      context.host.shadowRoot?.querySelector('output')?.setAttribute('data-rendered', 'true');
    });
    context.onCleanup(() => controller.abort());

    return {
      render: () => html`
        <label>${context.props.label}</label>
        <button type="button" @click=${() => { quantity.value += 1; }}>
          Add one
        </button>
        <output aria-live="polite">${quantity.value}</output>
        <button type="button" @click=${() => context.emit('change', { quantity: quantity.value })}>
          Apply
        </button>
      `,
    };
  },
});

export const parentUsage = html`
  <quantity-control
    label="Seats"
    @change=${(event: Event) => console.info((event as CustomEvent).detail.quantity)}
  ></quantity-control>
`;

This example answers the practical questions in order: a requirement becomes a Custom Element; label is a public property; context.state() is local state; change is a native output; render() owns the DOM; styles owns component CSS; lifecycle hooks attach and observe work; and onCleanup() releases the imperative listener. The step-by-step component guide explains the same flow with a symptom-first troubleshooting table.

Choose an authoring model ​

NeedUseWhy
Keep typed props, one native template root, a slot, and owned CSS togetherA .gluon presentational SFCThe official Vite plugin compiles the file to ordinary public Gluon component calls; it adds no SFC runtime or component instance.
A stateful Custom Element with concise setup-owned state and cleanupdefineGluonElement()It infers declared property and event types and creates the same native element contract without a handwritten subclass.
Inheritance or protected hooks such as createRenderRoot(), setupConnection(), teardownConnection(), or update()subclass GluonElementThe class API exposes those extension points and a stable host identity.
Stateless template composition with no independent host or connection lifecyclea functional componentIt is a render function, not a Custom Element instance.

Use defineGluonElement() for a new stateful component unless the component needs one of the class extension points in the second row. Use elementProperty<Value>() and elementEvent<Detail>() when a setup-based definition needs structured generic types that a constructor cannot infer. Choose .gluon only for the intentionally small presentational subset; the SFC authoring guide lists supported syntax and rejected Vue-style features.

Advanced: package and load a component library ​

Read this section after the first component works. Most applications should not start with manifests, loaders, or Storybook infrastructure; those are extension points for separately published component libraries.

A separately published library exposes ordinary public ESM exports plus a serializable ComponentLibraryManifest from @gluonjs/quarks. Each manifest entry names its public module, named export, layer, stylesheet ids, dependencies, accessibility contract, and optional Custom Element tag and Storybook story id. Validate untrusted JSON with validateComponentLibraryManifest() before a consumer resolves any module.

Create the consumer-owned boundary with createComponentLibraryLoader(). Its resolver imports only the requested declared entry and dependencies; status() and result() expose loading, loaded, and failed state without implicitly registering the whole library. Element entries register against the selected registry, and duplicate tags with a different constructor fail. Functional entries remain unregistered render functions.

When a loader receives both a style resolver and an explicit target, it retains only the loaded entries' constructable sheets. release() and dispose() release exactly those references while preserving sheets the target already owned. styleSnapshot() and validateStyleSnapshot() carry the same ordered style ids through SSR and hydration without replacing retained DOM. The loader never introduces a <style> fallback.

The repository's examples/component-library package, clean consumer, and Storybook catalog are the complete runnable reference. Storybook uses @gluonjs/gluon-components-vite so stories return native Gluon templates and receive exact canvas teardown without a Web Components adapter. The catalog uses the published exports for controls, interactions, accessibility checks, and visual baselines; it is developer evidence, not a replacement for the GLUON GOODS application acceptance flow. See the complete Storybook guide.

Declare properties ​

A GluonElement subclass can declare an input with @property() directly on the field, or list it in the static properties object and add a matching TypeScript declare field. Both forms create the same Gluon property contract. Use one form consistently within a class. Decorators reduce duplication; static properties remains useful when a project does not compile decorators.

Declaration keyWhat it doesDefault
typeUses the built-in String, Number, Boolean, Object, or Array attribute conversion.No conversion hint.
attributeRenames the attribute or disables attribute transport with false. Camel case otherwise becomes kebab case.The kebab-case property name.
reflectWrites an accepted property value back to its attribute.false
defaultSupplies the initial value. Use a factory for a fresh object or array per element.No value.
converterReplaces conversion from an attribute, to an attribute, or both.The converter selected by type.
hasChangedDecides whether a property write schedules an update.!Object.is(value, oldValue)
requiredReports GLUON_PROP_REQUIRED when the element connects without a provided value or default.false
validateReturns true for a valid value or a diagnostic message for an invalid value.No validation.

Use an attribute for short serializable HTML configuration such as featured or quantity="2". Use a property binding for structured data:

ts
html`<product-card .product=${product}></product-card>`;

The leading dot is significant: .product assigns the object to the element's JavaScript property. product=${product} would be an attribute binding and would cross the HTML string boundary instead.

A reflected value must have a stable text representation and be useful to CSS, HTML inspection, or another platform consumer. Do not reflect application-owned objects merely to duplicate them in markup.

Decorator equivalents ​

Import decorators from the explicit public subpath:

ts
import { customElement, property, state } from '@gluonjs/core/decorators';
DecoratorUse it forEquivalent without decorators
@customElement('product-card')Register a GluonElement subclass under a Custom Element tag.defineElement('product-card', ProductCard) after the class.
@property(options)Declare a public reactive field or accessor. It accepts the same options listed above.An entry in static properties plus a matching declare field.
@state(options)Declare private component-owned reactive state. It never reads an attribute and never reflects one.A static properties entry with attribute: false and reflect: false, plus a private field.

Prefer a normal class field such as @property() label!: string when Gluon should own the accessor. An auto-accessor such as @property() accessor label = 'Ready' is also supported. Use a default factory for mutable values; a field initializer is evaluated once for each element, while default: () => value follows the same explicit contract in both authoring forms.

Standard TypeScript decorators are recommended. Do not enable experimentalDecorators; keep useDefineForClassFields enabled. The official Vite plugin performs the required browser transform:

ts
// vite.config.ts
import { defineConfig } from 'vite';
import gluon from '@gluonjs/vite';

export default defineConfig({ plugins: [gluon()] });

An existing legacy-decorator project can use gluon({ decorators: 'legacy' }), experimentalDecorators: true, and useDefineForClassFields: false. Do not mix standard and legacy decorator semantics in one build.

Declare and emit events ​

The generic argument to GluonElement<Events> maps event names to their detail types. The static events field supplies runtime behavior. Calling emit(name, detail) validates the detail and dispatches a native CustomEvent.

Event declaration defaults are deliberate:

  • bubbles: true lets an ancestor listen without wiring every intermediate component;
  • composed: true lets the event cross the component's Shadow DOM boundary;
  • cancelable: false means preventDefault() has no effect until the component explicitly opts into cancellation;
  • validate uses the same true or diagnostic-message contract as a property validator.

For a cancelable event, emit() returns false after a listener calls preventDefault(). The emitting component can use that result to skip its default follow-up action.

In a Gluon template, bind an event with @event-name. Pass event(listener, options) when native addEventListener options such as once, capture, passive, or signal are required. The renderer removes template listeners when their owner is replaced, suspended, or unmounted. For an imperative addEventListener, retain the callback and remove it during connection cleanup, or supply an AbortSignal owned by that connection.

There is intentionally no event decorator. Event names and payloads form one component-wide public contract, so keep the generic GluonElement<Events>, the static events declaration, and emit() together. The @event-name syntax is a template listener binding; it is not a TypeScript decorator.

Complete compiled example ​

These two compiled examples implement the same public component boundary. The first uses decorators; the second uses plain TypeScript with static declaration objects. Both combine a structured property, primitive attributes, reflection, validation, a typed cancelable event, a template listener, and native listener options.

Decorator form ​

ts
import {
  GluonElement,
  event,
  html,
  type ComponentEventMap,
  type EventDeclarations,
} from '@gluonjs/core';
import { customElement, property, state } from '@gluonjs/core/decorators';

interface ProductSummary {
  readonly id: string;
  readonly name: string;
}

interface ProductCardEvents {
  readonly 'add-to-bag': {
    readonly productId: string;
    readonly quantity: number;
  };
}

@customElement('product-card-decorated')
export class DecoratedProductCard extends GluonElement<ProductCardEvents> {
  static override readonly events = {
    'add-to-bag': { cancelable: true },
  } satisfies EventDeclarations<ProductCardEvents>;

  @property({
    type: Object,
    attribute: false,
    required: true,
    validate: (value: ProductSummary) => value.id.length > 0 || 'A product id is required.',
  })
  product!: ProductSummary;

  @property({
    type: Number,
    default: 1,
    reflect: true,
    validate: (value: number) => Number.isInteger(value) && value > 0
      || 'Quantity must be a positive integer.',
  })
  quantity!: number;

  @property({ type: Boolean })
  featured!: boolean;

  @state({ default: false })
  private accepted!: boolean;

  private addToBag(): void {
    this.accepted = this.emit('add-to-bag', {
      productId: this.product.id,
      quantity: this.quantity,
    });
    if (this.accepted) this.quantity = 1;
  }

  protected override render() {
    return html`
      <article data-featured=${this.featured}>
        <h2>${this.product.name}</h2>
        <button type="button" @click=${() => this.addToBag()}>
          Add ${this.quantity} to bag
        </button>
        <output>${this.accepted ? 'Accepted' : 'Not submitted'}</output>
      </article>
    `;
  }
}

const inventory = new Set(['orbit-lamp']);

function onAddToBag(nativeEvent: Event): void {
  const addEvent = nativeEvent as ComponentEventMap<ProductCardEvents>['add-to-bag'];
  if (!inventory.has(addEvent.detail.productId)) addEvent.preventDefault();
}

const product = { id: 'orbit-lamp', name: 'Orbit Lamp' } satisfies ProductSummary;

export const decoratedCard = html`
  <product-card-decorated
    .product=${product}
    .quantity=${2}
    featured
    @add-to-bag=${event(onAddToBag, { once: true })}
  ></product-card-decorated>
`;

Plain TypeScript form ​

ts
import {
  GluonElement,
  defineElement,
  event,
  html,
  type ComponentEventMap,
  type EventDeclarations,
  type PropertyDeclarations,
} from '@gluonjs/core';

interface ProductSummary {
  readonly id: string;
  readonly name: string;
}

interface ProductCardProperties {
  product: ProductSummary;
  quantity: number;
  featured: boolean;
  accepted: boolean;
}

interface ProductCardEvents {
  readonly 'add-to-bag': {
    readonly productId: string;
    readonly quantity: number;
  };
}

export class ProductCard extends GluonElement<ProductCardEvents> {
  static override readonly properties = {
    product: {
      type: Object,
      attribute: false,
      required: true,
      validate: (value: ProductSummary) => value.id.length > 0 || 'A product id is required.',
    },
    quantity: {
      type: Number,
      default: 1,
      reflect: true,
      validate: (value: number) => Number.isInteger(value) && value > 0
        || 'Quantity must be a positive integer.',
    },
    featured: Boolean,
    accepted: { attribute: false, reflect: false, default: false },
  } satisfies PropertyDeclarations<ProductCardProperties>;

  static override readonly events = {
    'add-to-bag': { cancelable: true },
  } satisfies EventDeclarations<ProductCardEvents>;

  declare product: ProductSummary;
  declare quantity: number;
  declare featured: boolean;
  private declare accepted: boolean;

  private addToBag(): void {
    this.accepted = this.emit('add-to-bag', {
      productId: this.product.id,
      quantity: this.quantity,
    });
    if (this.accepted) this.quantity = 1;
  }

  protected override render() {
    return html`
      <article data-featured=${this.featured}>
        <h2>${this.product.name}</h2>
        <button type="button" @click=${() => this.addToBag()}>
          Add ${this.quantity} to bag
        </button>
        <output>${this.accepted ? 'Accepted' : 'Not submitted'}</output>
      </article>
    `;
  }
}

defineElement('product-card', ProductCard);

const inventory = new Set(['orbit-lamp']);

function onAddToBag(nativeEvent: Event): void {
  const addEvent = nativeEvent as ComponentEventMap<ProductCardEvents>['add-to-bag'];
  if (!inventory.has(addEvent.detail.productId)) addEvent.preventDefault();
}

const product = { id: 'orbit-lamp', name: 'Orbit Lamp' } satisfies ProductSummary;

export const card = html`
  <product-card
    .product=${product}
    .quantity=${2}
    featured
    @add-to-bag=${event(onAddToBag, { once: true })}
  ></product-card>
`;

Lifecycle and ownership ​

One element can disconnect and reconnect, so connection-owned work must not live forever.

  1. Construction creates the render root, installs property accessors, captures values assigned before Custom Element upgrade, and applies defaults.
  2. Connection validates declared properties, adopts styles, creates the connection effect scope, runs setupConnection(), and queues the first render.
  3. The first successful render runs onConnected() and then onUpdated().
  4. A later update runs onBeforeUpdate(), renders, and then runs onUpdated().
  5. Disconnection stops the connection scope, suspends rendered listeners and refs, runs onDisconnected(), and finally runs teardownConnection().

requestUpdate() schedules a deduplicated render. updateComplete is the promise for the currently scheduled render and resolves after its update hooks finish. Declared property writes and reactive dependencies already schedule updates; call requestUpdate() only after changing non-reactive state that the template reads.

For setup-based components, register the equivalent work with context.onConnected(), context.onUpdated(), context.onDisconnected(), and context.onCleanup(). Setup executes once per connected lifetime; explicitly keyed state() and reactiveState() values survive a reconnect.

Task-oriented lifecycle recipes ​

  • Start a fetch or subscription in onConnected() and keep its controller in the connection scope.
  • Attach an imperative listener with an AbortSignal, then abort it from onCleanup().
  • Initialize a DOM-dependent library in onConnected() when it needs the first committed node, or in onUpdated() when it must follow later DOM changes.
  • Use onUpdated() for measurement/reporting after a render, not to mirror a reactive value back into the template.
  • Put cancellation, subscription release, timer cleanup, and third-party disposal in onCleanup(); it runs when that connection scope ends.

The platform may disconnect and reconnect the same element. Render-owned bindings are restored automatically; connection-owned work must be registered again. See the lifecycle timeline before using a hook.

Component troubleshooting workflow ​

Start with the visible symptom, then inspect the public boundary and run the smallest check:

SymptomLikely causeDiagnostic/checkMinimal fix
Property is text instead of an objectAttribute binding usedInspect the rendered attribute and propertyUse .value=${object}.
Update never happensRender did not read reactive stateCheck the dependency in the template and run updateCompleteRead .value during render or declare the property.
Hook runs twiceElement reconnected or work was put in onUpdated()Log connect/update/disconnect with the Devtools timelineScope first-connection work to onConnected().
Listener leaksImperative listener has no owner cleanupRemove and reinsert the element in a browser testUse context.onCleanup() or event(..., { signal }).
Shadow DOM style is missingDocument CSS cannot cross the rootInspect adoptedStyleSheets and style ownershipUse static styles or a retained constructable sheet.

For SSR, hydration, and async symptoms use the universal rendering guide and its troubleshooting workflow.

Public class map ​

Most application code needs only the first four classes. Error classes are listed because applications may catch them or inspect their structured fields; they are not alternative component bases.

Application and reactivity classes ​

ClassUse it for
AnalyticsOwn provider-independent semantic events, consent, enrichment, and SSR-safe analytics delivery for one application.
GluonElementSubclass it for a stateful Custom Element that needs protected class extension points.
LitCompatElementUse only while migrating Lit lifecycle methods; new Gluon components should use GluonElement and native lifecycle hooks.
GraphQLRequestErrorHandle provider HTTP or GraphQL response failures at an application data boundary; it is not a component base class.
GraphQLTimeoutErrorHandle a request timeout at the owning data boundary; it is not a component lifecycle API.
TemplateResultThis is the immutable result returned by html; return it from render code rather than constructing it directly.
EffectScopeGroup reactive effects and cleanup under one stop() boundary; effectScope() is the public factory.
StoreManagerOwn Store definitions and live Store instances for one application, request, or test; create it with createStoreManager() and call dispose().

Component-library classes ​

ClassUse it for
ComponentLibraryLoaderResolve an explicitly requested public component entry, observe cache state, retain target-owned constructable stylesheets, and validate request-local SSR style snapshots before hydration.
GluonGraphElementRender an optional interactive graph with typed node/group/link input, deterministic layout, selection, pan, and zoom; hosts keep their domain controls and persistence.
JsonFormsElementRender the supported JSON Forms subset as a form-associated Custom Element; prefer the JsonForm() template helper when composing it inside a Gluon render tree.
JsonSchemaResolutionErrorHandle bounded local JSON Pointer failures through the resolver's stable diagnostic keyword.

Tooling classes ​

ClassUse it for
DevtoolsProtocolRegister inspectable applications, record serializable timeline events, take snapshots, and subscribe a Devtools client.
GluonDevtoolsBridgeConnect application, Router, Store, render, event, and error signals to DevtoolsProtocol; dispose it with its owner.
GluonLanguageServiceAnalyze open TypeScript documents for Gluon diagnostics, completion, hover, definitions, rename edits, and semantic tokens.
GluonProtocolServerAdapt GluonLanguageService to the repository's JSON-RPC/LSP message contract.
GluonMcpServerServe the read-only API manifest, diagnostics, and validation tools through the stable MCP request contract.

Error classes ​

ClassWhere it comes from
AsyncTimeoutErrorAn async component exceeded its configured timeout; inspect timeout.
HydrationMismatchErrorCore hydration found mismatches while recovery was configured as throw; inspect mismatches.
LegacyComponentStyleConflictErrorA legacy component stylesheet conflicts with usage-driven style ownership; inspect componentStyleId.
UiHydrationErrorUI stylesheet hydration found missing, duplicate, reordered, or mismatched carriers; inspect mismatch.
GluonSfcCompileErrorPresentational .gluon compilation rejected malformed, stateful, or ambiguous source; inspect code and filename.
SsrRenderErrorSSR received an invalid value or unsupported directive; inspect code.
ComponentStyleHydrationErrorComponent stylesheet hydration reported a typed mismatch; inspect mismatch.
HydrationMarkerTransportErrorSSR marker transport is missing, invalid, or tampered; inspect mismatch and preserve the declarative ShadowRoot.
SsrTransportErrorThe hydration style transport is unsupported, malformed, or conflicts with recovery; inspect code.
ProgressivePatchErrorA progressive SSR patch could not be applied; inspect code to distinguish abort, boundary, patch, and style failures.
AddComponentErrorcreate-gluon component generation rejected input or a filesystem safety condition; inspect code.
ScaffoldErrorProject scaffolding rejected CLI options, a project name, or the target directory; inspect code.
VueMigrationAnalyzerErrorVue migration analysis could not start or exceeded a resource budget; inspect exitCode.

Next references ​

Version-matched documentation. Examples are checked against the public package contracts.