STATOR

DOC-001 · v0.1 · proof of concept

A state-machine framework for the web.

State machines are the unit of composition. The server/client boundary is declared at the module level. DOM renders where state lives, usually the server, so the client mostly receives HTML and slot patches.

/* NOTE */

This is early. The demo at demo.statorjs.dev works end-to-end but the framework's edges are rough and held together with dental floss and super glue. Sharing it now to test the ideas, before sinking more time into polishing the wrong shape. No npm release yet, just an open repo and a live demo.

SPEC-A Architectural decisions
  1. 01

    DOM renders where state lives.

    Each machine declares its lifecycle. Today every machine is server-side, so the DOM that reads from it renders on the server and ships as HTML. No state synchronization, no hydration. A bounded client plane lands in V1 for state that only matters in one tab, like input drafts.

  2. 02

    State machines as the unit of composition.

    No components, no hooks, no stores. Each machine declares its events, transitions, actions, and selectors in one place.

  3. 03

    Module-level server/client boundary.

    Server code imports from @statorjs/stator/server, templates from /template, the browser runtime from /client. Crossing the line is an import error, not a runtime surprise.

  4. 04

    Explicit reads, not auto-tracking.

    Templates call read(machine, selector) per slot. Every reactive dependency is visible at the call site. No conditional-read footguns.

  5. 05

    Slot-level wire patches.

    When a machine transitions, the server diffs only the affected slots and emits text / attribute / html patches. No vdom, no full re-renders.

  6. 06

    Cross-machine events via declared subscriptions.

    Machines compose through named emits and receiver-side subscribes: entries. The graph is statically analyzable, not hidden in closures.

  7. 07

    Per-route opt-in SSE for live views.

    Routes that need cross-session live updates declare live: true and automatically open an SSE channel. Routes that don't, don't.

§1 Where the modern frontend stack hurts

Three frustrations drove this project. They show up everywhere in the modern frontend stack, and none of them get talked about as much as they should.

/ State coupled to UI

In most modern frameworks, your application's state lives inside the component tree. Hooks attach to components, providers wrap them, and stores subscribe to component instances.

Move a piece of business logic and you rewire components. Test a transition and you need a fake DOM. Refactor a feature and the blast radius is half the tree, because state boundaries and component boundaries got conflated.

State should be its own thing, not a property of where it happens to get read.

/ Shipping the renderer to the client

Most modern frameworks ship a JS bundle to the browser, fetch data over the network, and render the DOM there. The browser is the renderer of record.

That's complicated for no architectural reason. State has to sync between server and client, bundle size becomes a budget, and hydration mismatches become a class of bug. Loading, error, retry, and offline states pile up through the codebase.

It's also a security risk. Anything you ship to the browser is inspectable, and anything fetchable from it is replayable with different parameters. Server-side validation becomes load-bearing in a way that's easy to forget about until it bites you.

Render where state lives, and let browsers do what they're good at: displaying HTML.

/ The invisible RSC boundary

React Server Components ask you to keep components looking unified while execution quietly shifts between server and client. The intention is good.

The reality is that the boundary is load-bearing in ways that aren't visible from source. You discover where it actually fell by inspecting the bundle, or by reading the postmortem after secrets leaked into a client chunk. A "use client" directive at the top of a file is a long way from a guarantee about which code ran where.

§2 The paradigm

A few rules, applied consistently. The unit of composition is the machine. Every machine declares where it runs (app scope or per session), what it reads, what it emits, and how it transitions.

Templates read machine state through explicit slots, and events round-trip to the server. The server diffs and returns just the slots that changed.

/ Machines, declared

// machines/cart.ts
import { defineMachine } from '@statorjs/stator/server'
import ProductsMachine from './products.ts'

export default defineMachine({
  name: 'CartMachine',
  lifecycle: 'session',            // 'app' | 'session'
  reads: [ProductsMachine],         // declared dependencies

  context: { items: [] },
  initial: 'idle',
  states: {
    idle: {
      on: {
        ADD_ITEM: { actions: 'addItem', emit: 'ITEM_ADDED' },
      },
    },
  },

  actions: {
    addItem: (ctx, ev, { reads }) => {
      // The canonical price is read from ProductsMachine on the server.
      // The wire event carries only productId.
      const product = reads.ProductsMachine.byId(ev.productId)
      if (!product) return
      ctx.items.push({
        productId: ev.productId,
        unitPrice: product.price,
        quantity: 1,
      })
    },
  },

  emits: {
    ITEM_ADDED: { payload: (ctx) => ({ items: ctx.items }) },
  },

  selectors: {
    itemCount: (ctx) => ctx.items.reduce((s, i) => s + i.quantity, 0),
  },
})

Templates read machine state through explicit slot helpers. A read call registers a binding the server can diff against on later transitions.

// templates/header.ts
import { html, read } from '@statorjs/stator/template'

export default function header(cart) {
  return html`<a href="/cart">Cart (${read(cart, c => c.itemCount)})</a>`
}

/ The wire edge

Every event arriving at the server is shape-validated before any machine sees it. The framework knows which machine the event targets, which route the client is on, and which slots that route's templates registered.

A single POST produces a small JSON patch list, set this slot's text, replace that list's innerHTML, changes this element's class. etc. The ~1kB client script applies each patch by element id.

No call-arbitrary-server-function or exposed RPC surface. Just send an event to a machine and let the machine decides what to do.

§3 One architecture, three routes

Stator has one rendering architecture: server renders HTML, client receives slot patches over POST. The framework exposes one opt-in extension (live: true) for routes that need cross-session push. The demo at demo.statorjs.dev uses three routes to show what each piece looks like in practice.

ROUTE-A

/

Explicit reads · slot patches

Product list rendered server-side, shipped as HTML. Each "Add to cart" button is bound through three reads: its class, its label, and the header cart counter. Clicking sends one event, and the response is three patches addressed by slot. The rest of the page doesn't re-render.

product list

ROUTE-B

/checkout

State machine · guarded transitions

A three-state machine (shippingpaymentcomplete) drives the page. The template uses match() keyed on machine state to render only the current step. Guards block invalid transitions, so 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

Same architecture, plus live: true. An app-lifecycle machine subscribes to every session's cart emits and denormalizes the activity into a dashboard view. The route opens an SSE channel, and the framework pushes only the slots that changed. Open /admin in one tab, shop in another, watch the row update.

/admin live
§4 In context

Stator isn't the first framework to take any of these positions. Where it differs is worth being plain about. So is where the other framework is the better pick.

React + RSC

The big one. RSC's ambition is right: keep components unified while shifting execution. The implementation produces a boundary that bites in production. Stator makes the boundary module-level, with server code importing from one subpath and client code from another. Crossing them is an import error, not a build-graph surprise.

PICK REACT WHEN ecosystem depth, hiring pool, or React Native overlap matter more than paradigm correctness.

Phoenix LiveView

The closest spiritual ancestor. LiveView pioneered server-canonical fine-grained rendering and slot-based wire diffs. Stator borrows directly from this lineage. Differences: JavaScript instead of Elixir, state externalized to a pluggable Store (in-memory or Redis) rather than held in BEAM process memory, and explicit state machines as the modeling unit.

PICK LIVEVIEW WHEN you can be on the BEAM and want the elegance of in-memory session state.

Hotwire / Turbo

Similar server-canonical philosophy and HTML-over-the-wire mechanism, coarser-grained. Turbo Frames swap chunks rather than addressing individual slots, and there's no formal state-machine model: state lives in Rails controllers and models.

PICK HOTWIRE WHEN you're already a Rails shop and the app is shaped like CRUD.

§5 What works, what's planned

/// WORKING

  • Server runtime: per-request actor hydration from a pluggable Store
  • defineMachine wrapping XState v5 with framework metadata, selectors, and declared emits
  • Module-level server/client boundary via subpath exports
  • File-based machine and route discovery, with topological reads-graph validation
  • Template primitives: read, each, when, match, on
  • Compound attribute directives: class:list, style:list
  • Slot-level wire patches (text / attr / html), scope-subsumed
  • Cross-machine subscriptions with declared payloads and auto-injected sourceSessionId
  • reads: proxies available from templates and from actions/guards
  • Per-route opt-in SSE (live: true) with cross-session fan-out
  • Pluggable Store: InMemoryStore, RedisStore, CachedStore
  • Per-session TTL refreshed on activity

/// PLANNED (V1)

  • Client-side machines (defineLocalMachine) for ephemeral browser state
  • SFC compiler with .stator format (frontmatter + JSX body + scoped styles)
  • Typed event payloads and end-to-end send / action / guard typing
  • Schema CLI export for tooling and LLM context
  • Cross-machine event inbox (app→session delivery, multi-replica)
  • Compile-time slot analysis
  • Hot reload, dev tools (machine inspector, slot inspector, event trace)
  • Proper router with parameter patterns
  • Form abstractions
  • Auth patterns

Sharing this now, before the V1 list is built, is the point. The goal is to find out whether the paradigm is worth building toward, or whether there's a fundamental objection we haven't seen yet. The repo is open. Issues and emails are both welcome.