STATOR

DOC-002 · v2.5 · released

Render HTML where your state lives.

Your data lives on the server. Stator renders the page there too — when data changes, the browser gets a tiny patch, just the bit that changed. State machines are the unit of composition, and one .stator file holds the logic, the markup, and the small amount of browser code a page actually needs — the server/client line drawn by where you put an import and checked by the compiler.

/* RELEASED */

What this is: released software, now at 2.5 — on npm today via pnpm create stator, with docs at docs.statorjs.dev and a deployed reference demo at demo.statorjs.dev. The API grows by addition. Breaking changes are the rare exception.

What it isn't: mature. Stator is a small team moving fast on an experimental new direction for web frameworks. The vision and fundamentals are set — what we're still finding are the rough edges of an approach this different.

Where it's going: every example pressure-tests the API, and what it proves missing becomes a primitive. The full shipped / planned / not-a-goal ledger is in §6.

§1 Why another framework

Three structural frustrations drove this. 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 ref:query on:input={run}>
  <button on:click={run} disabled={read(search, s => s.notReady)}>Search</button>
  <p>{read(search, s => s.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 local machine runs here; read() 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.
  • read(machine, selector) — state out. One spelling on both sides of the boundary; the input itself owns its draft and commits by typed event.
  • 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's shipped, and what isn't

The fastest way to evaluate a framework is to know its edges. Here are ours, plainly: what's shipped as of 2.5, what's planned next, and what Stator is deliberately not going to do.

/// SHIPPED

  • 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; read() / on: / ref:
  • Startup hook — boot.ts (defineBoot) runs a long-lived source (poll, subscription) into the app-machine graph, plus deploy-aware client reload on reconnect (2.5)
  • .env / .env.local loading, and signed cookies (setSigned/getSigned) for sealed short-lived state — OAuth state, magic links (2.4)
  • Security & middleware round — middleware.ts, cross-site write guard with trustedOrigins, cors()/securityHeaders(), session claims + cookie surface, serverOnly events (2.3)
  • The stator CLI (dev/build/start/check) and a first-class stator.config.ts — no hand-written entry files (2.2)
  • Island frontmatter fences — server work an island owns, a server component's contract (2.1)
  • The forms pattern — platform-guarded drafts, typed commit events, server-rendered pre-fill, two-tier validation (2.0)
  • Typed machine-mediated dispatch
  • File routing, path params, API routes, response directives
  • Per-route opt-in SSE for cross-session live views
  • One display primitive — read() on server and client machines alike, lowered by import location; every state change is a declared, typed event (2.0)
  • Typed client events — unions derived from on keys or declared like defineMachine; typos are compile errors (1.9)
  • Typed component surfaces — per-element HTMLAttributes, attribute spread, on: forwarding (1.9)
  • Comment-marker regions — reactive lists and branches work inside tables and selects, no injected wrapper nodes (1.8)
  • Machine-level on: — any-state handlers for completion events (1.8)
  • Data GET routes — JSON, RSS, and text endpoints reading the same machines pages render, extension URLs like rss.xml.ts, ETag/304 conditional GETs (1.7)
  • Flaky-network resilience — pending and connection signals, idempotent retried events, reconnects resync in place (1.6)
  • Async data via defer — parallel awaits in synchronous routes (1.4)
  • Live item reads in lists — text and attribute position, patched across moves (1.4)
  • Async effects and after timeouts, restart-safe — the work-lifetime contract (1.3–1.5)
  • Templates typecheck in CI and the editor — plain tsc plus a Volar language server (1.5)
  • Session rotation — rotateSession() on privilege change (1.2)
  • 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
  • Presence and connection-lifecycle primitives
  • 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
  • Awaiting inside actions and guards — async is host-scheduled effects, never under the session lock
  • 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.