Skip to content

Build one stateful component ​

This is the shortest path from HTML and TypeScript to a reusable Gluon component. It uses one native Custom Element, one public property, local state, one event, a stylesheet, a first-render action, update observation, and cleanup. Read it before comparing every authoring model.

The requirement ​

Build a quantity control that can be placed in plain HTML, increments from a button or keyboard shortcut, announces its current value, and tells its parent when the user applies the value.

The ownership is deliberately small:

ConcernOwner
labelParent input/property
quantityComponent-local reactive state
changeNative component output event
Button and output markupComponent render function
Keydown listenerComponent connection
Listener cancellationComponent cleanup

The complete flow ​

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>
`;

The setup() function runs for the element's connected lifetime. context.state creates a reactive value; reading it in render() makes the template update when a button changes it. The parent receives a normal CustomEvent, not a framework-specific callback.

The component is a real Custom Element: it has a host, a Shadow Root, native connection boundaries, and a tag name. A parent can use it from HTML after its module has been imported:

html
<quantity-control label="Seats"></quantity-control>
<script type="module" src="./quantity-control.ts"></script>

When each lifecycle hook is useful ​

MomentUseExample in this component
First successful renderonConnected()Focus the now-existing button and attach connection-owned work.
Before a later renderonBeforeUpdate()Prepare work from changed inputs before DOM commits.
After a renderonUpdated()Measure or initialize a DOM-dependent library.
DisconnectonDisconnected()Stop an explicit connection activity.
Any owned releaseonCleanup()Abort listeners, subscriptions, timers, and requests.

The sequence is: connect → first render → connected callback → updated callback → later property/state update → before-update callback → render → updated callback → disconnect → cleanup. Disconnecting does not destroy the element object; reconnecting starts a new connection scope. Keyed state created with context.state() survives that reconnect, while connection-owned listeners must be registered again.

Do not add a lifecycle hook just to redraw after a reactive write. A state or declared property read by render() already schedules the update. Use a hook when the work talks to an imperative API, a subscription, or committed DOM.

Troubleshoot from the symptom ​

SymptomLikely causeFirst checkFix
Input shows [object Object]An object crossed the HTML attribute boundaryCheck the binding syntaxUse .product=${product} property binding.
State changes but UI does notState was copied outside the render dependencyCheck that render reads .valueRead the reactive state in the template.
Listener fires after removalImperative listener was not connection-ownedRemove/reinsert the element and watch the countUse context.onCleanup() or an abort signal.
DOM library sees no elementInitialization ran before the first renderCheck whether the queried node existsInitialize in onConnected() or onUpdated().
Event crosses no host boundaryThe event is not composedInspect the event declarationUse the native composed/bubbling event contract required by the parent.

For renderer diagnostics, use the diagnostic reference and the tooling guide. The components guide then explains class-based APIs, properties, events, and advanced extension points.

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