6. A client component
Some state has no business on the server. A light/dark theme toggle should flip instantly, persist in the browser, and never cost a round trip. That’s a client component — a whole .stator file that compiles to a custom element running in the browser.
When state shouldn’t touch the server
Section titled “When state shouldn’t touch the server”Adding to a cart is server state: it’s authoritative, persisted, and shared with checkout. The theme is the opposite — it’s local, instant, and per-device. Routing it through the server would put the network in the interaction loop for no benefit. The rule of thumb: if losing the state on a server restart would be fine and a round trip would feel slow, it belongs on the client.
The whole-file element
Section titled “The whole-file element”Create templates/theme-toggle.stator. Unlike a server component, its root is a custom element (a lowercase, hyphenated tag) and it carries a <script>:
<theme-toggle> <button class="theme-toggle-btn" type="button" on:click={toggle} aria-label="Toggle theme"> <span>{read(theme, (t) => t.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' ? '☾ Dark' : '☀ Light') }, }, )
export class ThemeToggle extends StatorElement { theme = use(Theme, () => ({ mode: readStoredTheme() }))
toggle() { this.theme.send('TOGGLE') const mode = this.theme.mode document.documentElement.dataset.theme = mode try { localStorage.setItem('stator-theme', mode) } catch {} } }
function readStoredTheme() { try { return localStorage.getItem('stator-theme') === 'dark' ? 'dark' : 'light' } catch { return 'light' } }</script>
<style> .theme-toggle-btn { font: inherit; cursor: pointer; padding: 0.35rem 0.7rem; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); }</style>A client component can carry a scoped <style> just like a server one — same four-region .stator file, scoped the same way. That <script> is what makes this a client component. Its contents run in the browser — the machine imported (or defined) here is a client machine, decided entirely by the fact that it lives in the <script> rather than the frontmatter. The exported class name (ThemeToggle) must match the root tag (<theme-toggle>).
machine(context, behavior)
Section titled “machine(context, behavior)”machine(context, behavior) is the terse, in-<script> way to define a small client machine: the context (just data) first, then a behavior bag with an on map of events and select for derived values. The two are separate arguments so TypeScript can infer the context and type every handler and selector against it. Here Theme holds a mode, toggles it, and derives a label. It’s the same machine model as the server’s defineMachine, sized for a component. Event names are typed from the on keys — a send typo is a compile error — and a machine can declare a full payload-typed union with events: {} as MyEvents when it needs one (see the client components guide).
extends StatorElement + use()
Section titled “extends StatorElement + use()”StatorElement is the base class for a client component — it owns the machine lifecycle (started on connect, stopped on disconnect), plus this.attrs and this.refs. You declare a machine instance as a class field with use():
theme = use(Theme, () => ({ mode: readStoredTheme() }))this.theme is now a live instance: this.theme.send('TOGGLE') dispatches, and this.theme.label reads a selector — the same surface as a server machine, running locally.
the seed
Section titled “the seed”The second argument to use() is the seed — the machine’s initial context. It’s a thunk (() => ({ ... })) here, not a plain object, for a specific reason: it reads from localStorage (and could read this.attrs), neither of which is available when the class field is first constructed. A thunk seed is deferred until the element connects to the DOM, by which point attributes and the browser environment are ready. Use a thunk whenever the seed depends on this.attrs or the browser.
read() and on: in a component
Section titled “read() and on: in a component”Inside a client component, the directives mirror the server:
on:click={toggle}calls the component’stoggle()method on click.{read(theme, (t) => t.label)}keeps the<span>’s text in sync with thelabelselector — the sameread()you used on server machines, now against a client machine. Whenmodeflips,labelrecomputes and the span updates, with no server involvement.
Drop <ThemeToggle /> into the layout header you built in step 4, so it appears on every page. In templates/customer-layout.stator:
---import type { InstanceOf } from '@statorjs/stator/template'import type CartMachine from '../machines/cart.ts'import BaseLayout from './base-layout.stator'import ThemeToggle from './theme-toggle.stator'
const { cart } = Stator.props<{ cart: InstanceOf<typeof CartMachine> }>()---<BaseLayout> <div child="header" class="brand-bar"> <a href="/" class="brand">Desksmith</a> <a href="/cart">Cart ({read(cart, c => c.itemCount)})</a> <ThemeToggle /> </div> <children /></BaseLayout>
<style> .brand-bar { display: flex; align-items: center; gap: 1rem; max-width: 60rem; margin: 0 auto; padding: 1rem; border-bottom: 1px solid var(--border); } .brand { font-weight: 600; margin-right: auto; }</style>This is the complete file — the only change from step 4 is the added ThemeToggle import and <ThemeToggle /> in the header; the <style> block stays. The toggle now flips the theme instantly, from any page, with no server round trip — and because the catalog and header use the theme tokens from app.css, the whole app switches palette.
Avoiding a flash on load
Section titled “Avoiding a flash on load”There’s one rough edge: on a reload, the page paints in light mode for a frame before the component connects and re-applies the stored dark theme. Fix it by applying the stored theme before the first paint, with a small inline script in base-layout.stator’s <head>:
<script is:inline> if (localStorage.getItem('stator-theme') === 'dark') { document.documentElement.dataset.theme = 'dark' }</script>is:inline tells Stator to emit this <script> verbatim rather than treat it as a client component — the opt-out you’d use for any literal inline script. It runs synchronously before the body renders, so there’s no flash.
What you built · next
Section titled “What you built · next”A self-contained custom element with its own browser-side machine, persistence, and reactive binding — and not a single server round trip. In step 7 we go back to the server side and make the cart survive restarts.