ROUTE-A
/
Explicit reads · slot patches
Product list rendered server-side. Each "Add to cart" click sends one event; the response is a handful of patches addressed by slot. The rest of the page never re-renders.
DOC-002 · v1.0 · preview
Stator is a web framework where state machines are the unit of
composition and the DOM renders wherever its state lives — almost
always the server. One .stator file holds the
logic, the markup, and the small amount of browser code a page actually
needs, with the server/client line drawn by where you put an import and
checked by the compiler.
/* 1.0 PREVIEW */
This page describes the committed 1.0 design. The runtime, wire
protocol, routing, and live SSE are built and running — the demo at
demo.statorjs.dev
exercises them end-to-end. The 1.0 developer surface below — the
.stator compiler, the client model, typed
events — is specified and in build. We're publishing the shape first,
on purpose, so the API gets pressure before it ossifies.
Three frustrations drove this. They're structural, they show up across the modern stack, and Stator is the answer to all three at once.
In most frameworks your state lives inside the component tree. Hooks attach to components, providers wrap them, stores subscribe to instances. Move a piece of logic and you rewire components; test a transition and you need a fake DOM. State should be its own thing, not a property of where it happens to be read.
Ship a bundle, fetch data, render in the browser. Now state syncs across the wire, bundle size is a budget, hydration mismatch is a bug class, and anything fetchable is replayable with different params. Render where state lives and let the browser do what it's good at: displaying HTML.
React Server Components keep the source looking unified while execution
quietly shifts between server and client. The boundary ends up
load-bearing in ways you can't see from the file — you find where it
really fell by inspecting the bundle, or by reading the postmortem
after a secret leaked into a client chunk. A
"use client" at the top of a file is a
long way from a guarantee about which code ran where.
A Stator component is a .stator file with
three regions: a frontmatter fence and template that render on the
server, and an optional <script>
that runs in the browser. The rule for where code runs has one
clause: if it's imported inside the
<script>, it's client; otherwise it's
server. No "use client", no
coloring that spreads through your imports, no surprise at the bundle.
components/SearchBox.stator
--- // frontmatter + template render on the SERVER
import { html, read } from '@statorjs/stator/template'
import SearchMachine from './machines/search.ts'
---
<search-box>
<!-- server-rendered, updated by slot patches over the wire -->
<p>Results: {read(SearchMachine, s => s.summary)}</p>
<input bind:value={query}>
<button on:click={run} bind:disabled={notReady}>Search</button>
<p bind:text={error}></p>
</search-box>
<script> // this region runs in the BROWSER
import { machine, use } from '@statorjs/stator'
import SearchMachine from './machines/search.ts'
import { validate } from './lib/validate.ts'
// the draft is client-only state — never touches the network
const Draft = machine({
query: '',
on: { SET: (s, e) => { s.query = e.value } },
select: {
error: s => validate(s.query)[0] ?? '',
notReady: s => validate(s.query).length > 0,
},
})
export default class extends StatorElement {
draft = use(Draft)
run() {
if (this.draft.notReady) return
// commit the local draft to the SERVER machine — the one round-trip
SearchMachine.dispatch({ type: 'RUN', query: this.draft.query })
}
}
</script>
<style> // scoped to this component at build time
p { color: var(--ink); }
</style>
▚ Server
The template and its read() bindings render to HTML and
update through slot patches. SearchMachine imported here
is the authoritative, persisted machine.
▞ Client
Everything in <script> is browser code. The draft
machine runs locally; bind: writes to the DOM with no
re-render; SearchMachine.dispatch() is the visible hop
to the server.
You can read that file top to bottom and know exactly what ships to the
browser: the things in the <script>.
That's the whole mental model.
Stator never ships a virtual DOM or re-runs your template in the browser. The server renders the HTML; the browser mutates it with native DOM calls. Reactivity is one loop — subscribe → read a selector → write the node — the same shape the server uses to diff slots, just running locally. You declare it on the node it touches:
on:click={handler} — events in. Sends to a local machine, or
.dispatch() to a server one.bind:text / bind:value /
bind:disabled — state out. Two-way on form controls,
one-way everywhere else.ref:name — a typed handle to a server-rendered node,
for the rare imperative case (focus, a third-party widget). No
querySelector strings.
Client state is a machine too — the same machine type as the
server, just instantiated in the browser. A machine that touches
server-only capability (persistence, secrets, cross-session emit)
can't be imported into a <script>;
that's a compile error, not a runtime leak. And validation written once
runs on both sides: as a client selector for instant feedback, and as a
server guard that is the actual gate.
A machine declares its events, context, states, guards, actions, and
selectors in one place. Events are a typed union, so actions and guards
see a narrowed payload and dispatch is
checked at the call site — no stringly-typed sends.
machines/search.ts
import { defineMachine } from '@statorjs/stator/server'
type Events = { type: 'RUN'; query: string }
export default defineMachine({
name: 'SearchMachine',
lifecycle: 'session',
events: {} as Events, // the typed event surface
context: { history: [] as string[] },
initial: 'idle',
states: {
idle: {
on: {
RUN: {
when: (ctx, ev) => ev.query.trim().length >= 3, // guard
do: (ctx, ev) => { ctx.history.unshift(ev.query) }, // action
},
},
},
},
selectors: {
summary: (ctx) => ctx.history.join(' · ') || 'none yet',
},
})
An event arriving at the server is shape-validated, routed to its machine, and run. The framework knows which slots the current route registered, diffs only those, and returns a small JSON patch list — set this slot's text, flip that attribute, replace this list's HTML. A tiny client script applies each patch by id. There's no exposed RPC surface: you send an event to a machine, and the machine decides what happens.
And because the machine never learns the UI exists, §1's complaint — test a transition and you need a fake DOM — dissolves. This is the entire test for the guard above. No renderer, no mock server, no fake DOM; every business rule in your app tests at this speed.
machines/search.test.ts
import { createActor } from '@statorjs/stator/machine'
import SearchMachine from './search.ts'
const actor = createActor(SearchMachine).start()
actor.send({ type: 'RUN', query: 'ok' }) // guard blocks: too short
actor.send({ type: 'RUN', query: 'standing desk' })
expect(actor.getSnapshot().context.history).toEqual(['standing desk'])
Most routes need nothing more than the POST round-trip. Routes that want
cross-session push declare live: true and the
framework opens an SSE channel, fanning the same slot patches out to
every open connection whose route reads a touched machine. The demo's
three routes show the range:
ROUTE-A
/
Explicit reads · slot patches
Product list rendered server-side. Each "Add to cart" click sends one event; the response is a handful of patches addressed by slot. The rest of the page never re-renders.
ROUTE-B
/checkout
State machine · guarded transitions
A three-state machine drives the page; the template renders only the current step. Guards block invalid transitions — you can't submit shipping without a name and address. No hidden DOM for inactive steps.
ROUTE-C
/admin
Cross-session live · opt-in SSE
Add live: true. An app-lifecycle machine
subscribes to every session's cart and denormalizes a dashboard.
Shop in one tab, watch /admin update in
another, no polling.
The fastest way to evaluate a framework is to know its edges. Here are ours, plainly: what ships in 1.0, what's planned for after, and what Stator is deliberately not going to do.
/// IN 1.0
.stator SFC: frontmatter, template, client <script>, scoped styleson: / bind: / ref:dispatch/// PLANNED
/// NOT A GOAL
The "not a goal" column is load-bearing. Stator is small because it says no to a lot. If one of those lines is a requirement for you, the honest answer is that another tool fits better — and the next section says which.
Reach for Stator when
Look elsewhere when
Stator isn't the first framework to take any one of these positions. Where it differs is worth being plain about — and so is where the other tool is the better pick.
React + RSC
RSC's ambition is right — keep components unified while shifting
execution — but the boundary it produces is invisible from source and
bites in production. Stator makes the boundary a place you can see: an
import inside <script> is client,
everything else is server, and the compiler enforces it.
PICK REACT WHEN ecosystem depth, the hiring pool, or React Native overlap matter more than boundary clarity.
Phoenix LiveView
The closest spiritual ancestor: server-canonical, fine-grained, slot diffs over the wire. Stator borrows the lineage in JavaScript, with state externalized to a pluggable Store rather than held in process memory, and explicit state machines as the modeling unit.
PICK LIVEVIEW WHEN you can be on the BEAM and want horizontal scaling and in-memory session state today.
Hotwire / Turbo
Same server-canonical, HTML-over-the-wire philosophy, coarser-grained. Turbo swaps frames rather than addressing individual slots, and there's no formal state model — state lives in controllers and models.
PICK HOTWIRE WHEN you're already a Rails shop and the app is shaped like CRUD.