STATOR

DOC-002 · v1.0 · preview

Render where state lives.

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.

§1 Why another framework

Three frustrations drove this. They're structural, they show up across the modern stack, and Stator is the answer to all three at once.

/ State coupled to UI

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.

/ Shipping the renderer to the client

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.

/ The invisible boundary

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.

§2 One file, one obvious boundary

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.

§3 No renderer in the browser

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.

§4 Machines, typed end to end

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'])
§5 Live views, opt-in

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.

product list

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.

checkout

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.

/admin live
§6 What 1.0 is, and isn't

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

  • Server-canonical rendering with slot-level wire patches
  • .stator SFC: frontmatter, template, client <script>, scoped styles
  • Import-location server/client boundary, compiler-enforced
  • State machines with typed events, guards, actions, selectors
  • Client machines + custom elements; on: / bind: / ref:
  • Two-way form binding and isomorphic validation
  • Typed machine-mediated dispatch
  • File routing, path params, API routes, response directives
  • Per-route opt-in SSE for cross-session live views
  • Pluggable Store (in-memory / Redis), per-session TTL
  • Keyed lists; small (~6 KB) isomorphic engine, no XState

/// PLANNED

  • Horizontal scaling across replicas (Redis pub/sub backplane)
  • Durable app→session delivery: webhooks, cron, and background jobs pushing into idle sessions
  • Statechart depth: nested, parallel, and history states
  • Delayed transitions / timers
  • Editor LSP beyond syntax highlighting
  • Schema export for tooling and LLM context

/// NOT A GOAL

  • A virtual DOM or re-rendering JSX in the browser
  • Using React / Vue / Svelte components or their ecosystems
  • Async inside machines — async lives in routes and API handlers
  • A client-side SPA router as the default
  • Batteries-included backend: auth, ORM, and storage are yours to bring
  • Winning a hydration-cost benchmark by hydrating less cleverly — we don't hydrate

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.

§7 Is Stator for you?

Reach for Stator when

  • Your app is mostly server state — CRUD, flows, dashboards — with islands of local interactivity
  • You want the server/client line obvious in the source and checked by the compiler
  • You care about small client payloads and HTML that works before JS
  • You like modeling logic as explicit, testable state machines
  • A single always-on instance is fine to start (scale-out is on the roadmap, not a rewrite)

Look elsewhere when

  • You need to scale horizontally across replicas today — it's planned, not here yet; reach for Phoenix LiveView
  • The UI is a heavy client app: an editor, a canvas tool, a game, an offline-first PWA
  • You depend on the React/Vue ecosystem and its component libraries
  • You need a large community and battle-tested stability right now
  • Your team models domains as objects and services, and state machines feel like friction
§8 In context

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.