Skip to content

Client components

A client component is a whole .stator file that compiles to a custom element running in the browser. Reach for one when state should stay client-side — instant, local, no round trip.

The root is a custom element; the <script> exports a name-matched StatorElement subclass:

<theme-toggle>
<button on:click={toggle}><span bind:text={theme.label}></span></button>
</theme-toggle>
<script>
const Theme = machine({
mode: 'light',
on: { TOGGLE: (s) => { s.mode = s.mode === 'light' ? 'dark' : 'light' } },
select: { label: (s) => s.mode === 'dark' ? '' : '' },
})
export class ThemeToggle extends StatorElement {
theme = use(Theme)
toggle() { this.theme.send('TOGGLE') }
}
</script>

<theme-toggle>ThemeToggle must match.

machine({...}) defines a small client machine inline (on for events, select for derived values). use(Def, seed?) instantiates it as a class field — this.theme.send(...) and this.theme.label mirror a server machine.

The optional seed sets initial context. Pass a plain object for static values, or a thunk when the seed reads this.attrs or the browser (these aren’t available at field-construction; a thunk defers to connect):

qty = use(Qty, () => ({ unitPrice: this.attrs.unitPrice }))

Declare an attribute surface with a static coercer map. Author names are camelCase ↔ kebab DOM attrs; Boolean is a presence flag:

static attrs = { unitPrice: Number, selected: Boolean }
// reads <… unit-price="12" selected>

Elements marked ref:name are reachable as this.refs.name.

Actors start on connectedCallback and stop on disconnect. bind: directives and effect() subscribe to state and write the DOM natively — no client re-render.

An island’s markup is its own template — server-rendered content does not flow through it, and that’s a deliberate v1 boundary (like early Astro shipping without SSR: a known edge, owned). Three sanctioned channels cover composition with the server:

  1. Live attrs in. Pass a read() as an island prop and the attribute becomes a live server binding. Declared attrs are observed: implement ${key}Changed(next) and every patch lands there, coerced per your static attrs declaration.

    <stock-badge stock={read(inventory, (i) => String(i.stock[sku]))} />
    static attrs = { stock: Number }
    stockChanged(next) { this.render(next) }
  2. dispatch out. The one visible boundary crossing (below).

  3. Observing server-owned DOM. For regions the server keeps fresh outside the island, plain platform tools (querySelector, MutationObserver) are legitimate — islands are custom elements. Prefer channel 1 when the data can arrive as an attr.

  4. Server-rendered sections (the hydrate pattern). Island templates may contain server-evaluated expressions — props-driven maps with nested JSX, even a full component render passed as a prop. The shell renders them per use; the class hydrates by querying:

    <div class="opts" ref:opts>
    {props.options.map((o) => <button class="opt" data-id={o.id}>{o.label}</button>)}
    </div>
    connectedCallback() {
    super.connectedCallback()
    for (const b of this.querySelectorAll('.opt')) {
    b.addEventListener('click', () => this.pick(b.dataset.id))
    }
    }

    Note: on:/bind: directives don’t reach inside these server sections — wiring happens in the class, which is the point of the pattern.

To change server state from an island, dispatch to a server machine:

const result = await dispatch(CartMachine, { type: 'ADD_ITEM', productId: id })

dispatch resolves { ok, committed, patchCount } — three different facts. ok is transport; committed is whether the event actually transitioned a machine (a guard-dropped event is ok && !committed); patchCount is how many patches landed on this page. Buttons that announce success should look at committed.