@zakkster/lite-statechart
v1.1.0
Published
Zero-GC flat finite state machine for @zakkster/lite-signal. Compiled integer-indexed transition tables, wildcard cross-cutting transitions, terminal final states, synchronous re-entrant send, entry/exit/transition actions, guards, snapshot/hydrate. Built
Maintainers
Readme
@zakkster/lite-statechart
Zero-GC flat finite state machine for
@zakkster/lite-signal. Compiled integer-indexed transition tables, synchronous re-entrant send, sub-byte-per-op retention. Built for Twitch Extension auth lifecycles, lite-room peer presence, and any flat FSM you'd otherwise reach for XState to model.
A flat FSM compiled to integers, with the cost of a switch statement
lite-statechart is the state-machine layer designed to sit underneath the
zero-GC stack. It compiles a declarative FSM definition into integer-indexed
lookup tables at createStatechart() time, then dispatches events through
those tables with zero allocation per send() -- 19M+ ops/s on a 6-state
ring, sub-byte-per-op retention across 2M dispatches. The reactive surface
plugs straight into lite-signal's effect system, so the current state name
is a first-class ReadSignal your UI can subscribe to like any other.
It is not an XState replacement. Hierarchical, parallel, and history states
are explicitly out of scope -- the moment a machine needs those, it's outgrown
this package and belongs in a hierarchical engine. What this gets you instead:
a flat FSM with synchronous deterministic semantics, a six-method API surface,
and an end-to-end allocation profile measured under --expose-gc.
npm install @zakkster/lite-statechart @zakkster/lite-signalimport { createStatechart } from "@zakkster/lite-statechart";
import { effect } from "@zakkster/lite-signal";
const auth = createStatechart({
initial: "anonymous",
states: {
anonymous: { on: { JWT_RECEIVED: "validating" } },
validating: { entry: verify,
on: { VALID: "authenticated", INVALID: "anonymous" } },
authenticated: { entry: scheduleRefresh,
on: { JWT_EXPIRING: "validating", LOGOUT: "anonymous" } }
}
});
effect(() => auth.matches("authenticated") ? renderApp() : renderLogin());
auth.send("JWT_RECEIVED", { jwt: "..." });Synchronous, deterministic, glitch-free. No microtask queue, no allocations after warm-up, no implicit batching, no surprises.
Table of contents
- Why this exists
- What you get
- The case for compiling to integers
- Compile pipeline
- How a transition propagates
- API reference
- Edge cases pinned down
- Benchmarks
- Testing strategy
- What this is not
- Ecosystem
- Browser and runtime support
- Integration recipes
- FAQ
- npm scripts
Why this exists
State machines are everywhere in real apps: auth lifecycles, connection state, upload progress, multi-step wizards, retry/backoff loops, game scenes. The existing toolbox forks at two extremes:
- Ad-hoc
switchstatements. Free, but every machine ends up with its own bug shape: forgotten transitions, missing exit cleanup, ambiguous state under cascading events. - XState. Powerful and well-loved, but the runtime is large by design -- actors, history states, parallel regions, async services. Most consumer code uses 5% of it and pays for the rest.
lite-statechart is the missing middle: a single-file ESM with a six-method
API that compiles your declarative config to integer tables and dispatches
through them. The three constraints it was built under:
- Zero allocation in the hot path. A 60-fps Twitch overlay can't tolerate
GC pauses.
send()touches no heap. - Synchronous and deterministic. Every
send()either commits a transition synchronously or returnsfalse. No microtask scheduling. - One-read API surface. Six methods (
send,state,matches,can,snapshot,onTransition) plus one error type. No actors, no spawned children, no async.
flowchart LR
A[createStatechart config] --> B[Phase 1: assign state IDs]
B --> C[Phase 2: assign event IDs]
C --> D[Phase 3: build Uint16Array<br/>transition table]
D --> E[Phase 4: wire entry/exit/<br/>guards/transition actions]
E --> F[Object.freeze machine]
F --> G[send loop -- zero alloc]What you get
createStatechart(config)-- compile a declarative FSM into a frozen, immutable handle. All validation is up front; no first-dispatch surprises.machine.send(event, payload?) -> boolean-- dispatch synchronously.trueif committed,falseif no transition was defined or a guard rejected.machine.state-- ReadSignal-shaped accessor ((),.peek(),.subscribe(fn)) carrying the current state name. Tracks in effects/computeds.machine.matches(name)-- tracked predicate.machine.can(event)-- tracked predicate; reports topological availability without evaluating guards.machine.snapshot()-- captures{ state }for persistence; pair withhydrateto restore.machine.onTransition(fn)-- post-commit listener with safe add/remove semantics under mid-notify mutation.LiteStatechartError-- thrown for semantic violations (exit callingsend(), re-entry depth exceeded). Configuration errors throwTypeError/RangeError.
Full type definitions ship in Statechart.d.ts and
are referenced from package.json. Every public symbol has JSDoc.
The case for compiling to integers
A typical JS state-machine library stores transitions as a nested object:
states[currentState].on[event] -> targetState. Every dispatch is two object
property reads (potentially polymorphic), a string equality check, then a
target-state lookup. On hot machines, that's polymorphic inline cache misses
plus shape-change risk if states or events get added dynamically.
lite-statechart compiles the entire transition graph to a single
Uint16Array(numStates * numEvents). State and event names become SMI
integers at createStatechart() time. send() is then:
eventMap.get(eventName)-- string-keyed Map, monomorphictransitions[currentStateId * numEvents + eventId]-- integer-indexed TypedArray read, the fastest read shape V8 supports- Compare against
NO_TRANSITION(0xFFFF) sentinel
After warm-up, the hot path performs zero allocations:
| Op | Allocations | Notes |
| --------------------------- | ------------ | -------------------------------------------------- |
| send(event) | 0 | Two integer lookups + TypedArray read |
| send(event) rejected | 0 | Unknown event / no transition / guard reject |
| state() / state.peek() | 0 | Inherits lite-signal's zero-GC read path |
| matches(name) | 0 | Tracked read + one === compare |
| can(event) | 0 | Map lookup + TypedArray read |
| Listener notification | 0 | Pre-allocated listener pool, no per-fire closures |
| snapshot() | 1 POJO | Intentional; {state} for storage |
Guards and per-edge transition actions are lazy Array allocations: the
arrays only exist if at least one state defines one. A pure entry/exit
machine has both arrays at null -- zero overhead in dispatch.
Compile pipeline
flowchart TB
subgraph Phase1[Phase 1 -- state ID assignment]
P1A[walk config.states keys] --> P1B[assign 0..N-1<br/>to each state name]
P1B --> P1C[validate config.initial<br/>resolves to a known state]
end
subgraph Phase2[Phase 2 -- event ID assignment]
P2A[walk every states.on key<br/>across all states] --> P2B[dedupe event names<br/>assign 0..M-1]
end
subgraph Phase3[Phase 3 -- table population]
P3A[allocate Uint16Array<br/>numStates × numEvents] --> P3B[fill with NO_TRANSITION<br/>0xFFFF]
P3B --> P3C[walk transitions,<br/>resolve targets,<br/>write integers]
P3C --> P3D[lazy alloc guards<br/>+ transition actions]
end
subgraph Phase4[Phase 4 -- instance state]
P4A[allocate entry/exit<br/>function arrays] --> P4B[resolve hydrate or initial]
P4B --> P4C[allocate state signal]
P4C --> P4D[Object.freeze handle]
end
Phase1 --> Phase2 --> Phase3 --> Phase4All validation surfaces at createStatechart(), not at first send():
TypeError-- wrong shape (non-string names, non-function actions, non-objecton, etc.)RangeError-- unresolvable references, duplicates, self-transitions, out-of-range counts (> 65534states)
The Uint16Array transition table is what fixes the state count at 65534 --
0xFFFF is reserved as the NO_TRANSITION sentinel. In practice, machines
beyond a few dozen states are usually a sign that hierarchical decomposition
is wanted, which is out of scope here.
The frozen machine handle's transition table, entry/exit arrays, guard
array, and per-edge action array are all fixed for the instance lifetime.
There is no addState() / addTransition() after compile -- if you want
to add a state, you compile a new machine and hydrate it from the old
one's snapshot.
How a transition propagates
sequenceDiagram
participant U as User code
participant M as machine.send
participant Ex as Exit phase
participant C as Commit phase
participant L as Listener phase
participant T as Transition action
participant En as Entry phase
U->>M: send(event, payload)
M->>M: resolve eventId,<br/>fromStateId, toStateId
M->>M: guard reject -> return false
M->>M: depth check (max 32)
M->>Ex: from.exit(payload)
Note over Ex: send() from exit<br/>throws LiteStatechartError
Ex->>C: currentStateId = toStateId
C->>C: stateSignal.set(toName)<br/>(propagates to UI effects)
C->>L: notify onTransition listeners<br/>(state already = to)
L->>T: edge.action(payload)
Note over T: send() OK -- re-entrant<br/>cascade allowed
T->>En: to.entry(payload)
Note over En: send() OK -- cascade<br/>(common: validate-and-route)
En-->>U: return truePhase order is documented contract. Subscribers reading state.peek()
inside an onTransition listener always see the new state -- commit happens
before notify.
Re-entrancy rules:
- Entry actions may call
send(). The inner send processes synchronously to completion before the outer entry returns. This is the common pattern for validate-and-route:validating.entrychecks a predicate and immediately sendsVALIDorINVALID. - Transition actions (per-edge) may call
send(). Same semantics. - Exit actions may not call
send(). State during exit is ambiguous -- a re-entrant send would either run a second exit on the same state (double-cleanup) or silently overwrite the outer transition's target. Both are worse than the loudLiteStatechartErrorthe engine throws instead.
Depth limit. Re-entrant chains are capped at 32. The most pathological real-world case I've encountered is an auth flow that cascades through ~8 states on token refresh; the cap exists to catch infinite-send bugs during development, not to constrain legitimate use.
Listener fire order under nesting. When entry triggers an inner send(),
the inner listener fires from within the inner send's notify phase, and the
outer listener fires from the outer notify phase. Chronological:
machine.onTransition((f, t) => console.log(f + "->" + t));
machine.send("GO"); // a -> b -> c via cascading entry
// Logs:
// a->b (outer notify)
// b->c (inner notify, fires inside outer's entry)Walked top-down it matches reading the code. Walked by "where the listener call sits on the call stack" it's the same thing.
API reference
Top-level
import { createStatechart, LiteStatechartError } from "@zakkster/lite-statechart";createStatechart
const m = createStatechart({
initial: "anonymous", // required
states: { /* ... */ }, // required
hydrate: { state: "anonymous" } // optional, restore from snapshot
});StateDef:
{
entry?: (payload, ctx) => void, // 1.1+: ctx allocated only if arity >= 2
exit?: (payload) => void, // fires on transition OUT (cannot call send)
on?: { [event]: TransitionDef },
final?: boolean // 1.1+: terminal state (see below)
}Entry context (1.1+):
If an entry action declares arity >= 2, it receives { signal } as the
second argument. ctx.signal is a state-scoped AbortSignal that aborts:
- when the machine transitions OUT of the state (between exit and the new state's entry)
- when
dispose()is called
This makes async entry actions structurally cancellable -- no manual listener bookkeeping, no leaked fetches when the machine moves on:
validating: {
entry: async (payload, ctx) => {
try {
const res = await fetch("/verify", { signal: ctx.signal });
if (res.ok) machine.send("VALID");
else machine.send("INVALID");
} catch (e) {
if (e.name !== "AbortError") machine.send("FATAL", e);
// AbortError just means "the machine moved on" -- drop it silently
}
},
on: { VALID: "authenticated", INVALID: "anonymous" }
}Entry actions with arity 0 or 1 (the legacy 1.0 form) allocate no
AbortController -- the implementation checks entry.length and only
constructs ctx when needed. Existing 1.0 entry actions pay zero overhead.
TransitionDef:
"targetStateName" // string shorthand
| { target: "targetStateName", // object form
guard?: (payload) => boolean, // synchronous, payload-aware
action?: (payload) => void } // per-edge, between commit and entryWildcard state (1.1+):
The reserved key "*" in states declares a transition map that expands at
compile time into every real state that does NOT define the event itself and
is NOT final. Per-state entries take precedence; the wildcard fills gaps.
states: {
"*": { on: { CLOSE: "closed", FATAL: "error" } },
anonymous: { on: { JWT_RECEIVED: "validating" } },
validating: { on: { VALID: "authenticated", INVALID: "anonymous" } },
authenticated: { on: { JWT_EXPIRING: "validating" } },
closed: { final: true },
error: { final: true }
}"*" supports only on. Wildcard target "*" is rejected. A wildcard
transition whose target equals the state being expanded onto is silently
skipped (the wildcard means "any state EXCEPT the target"). Compile-time
expansion only -- the runtime transition table is identical in shape to a
hand-rolled equivalent.
Final states (1.1+):
final: true marks a state as terminal:
send()andcan()returnfalsewhile in a final state.- The state signal,
onTransition,matches(),subscribe(), andsnapshot()remain observable. Consumers can render a "closed" or "error" screen againststate.peek()indefinitely. dispose()is orthogonal -- the consumer chooses when to release the machine's pool slot.- A final state may have an
entryaction; it must NOT haveon(rejected at compile time -- transitions on a terminal state are dead code). - Hydrating directly into a final state is allowed.
machine.send
machine.send(event: string, payload?: any): booleantrue if a transition committed, false if the event was unknown, no
transition was defined for the current state, or a guard rejected. Throws
TypeError if event is not a string. Throws LiteStatechartError if
called from inside an exit action or if re-entry depth (32) is exceeded.
machine.state
ReadSignal-shaped accessor for the current state name:
machine.state() // read + track (use in effects / computeds)
machine.state.peek() // read untracked
machine.state.subscribe(fn) // fires immediately + on every change
// returns idempotent Disposemachine.matches / machine.can
machine.matches(name: string): boolean // tracked, throws on non-string
machine.can(event: string): boolean // tracked, throws on non-stringmatches tracks the state signal so effect(() => render(machine.matches("ready")))
re-runs on state change. can reports topological availability without
evaluating guards -- guards may reject at dispatch time based on payload.
machine.snapshot / hydrate
const snap = machine.snapshot(); // -> { state: "validating" }
const m2 = createStatechart({ ...cfg, hydrate: snap });snapshot() allocates one {state} POJO -- the only intentional allocation
in the surface, called rarely.
machine.onTransition
const dispose = machine.onTransition((from, to, event, payload) => {
log.push(from + "->" + to);
});Listener fires after commit and before transition/entry actions. Safe under mid-notify add/remove:
- Listener removing itself: skipped immediately, no double-fire
- Listener removing another not-yet-fired: that listener is skipped
- Listener adding a new listener: new listener does NOT fire for the current transition; fires on the next one
dispose()is idempotent
The exhaustive matrix lives in
test/07-listeners.test.mjs.
machine.dispose
machine.dispose(); // idempotentReturns the machine's internal signal node to the lite-signal pool and clears
the listener pool. Idempotent. Required if your app compiles statecharts
in a loop (per route, per game level, per component mount); without it, every
machine permanently consumes one slot from lite-signal's default registry and
the 1024th compile/discard cycle hits CapacityError.
After dispose:
send()/can()returnfalse(silent no-op)state()/state.peek()/snapshot()/matches()return the state-name snapshotted at dispose-timestate.subscribe(fn)returns a no-op dispose without registeringonTransition(fn)returns a no-op dispose without registering- Existing
onTransitionsubscribers stop firing (the listener pool is cleared)
For machines that live for the application lifetime, dispose() is optional --
process exit reclaims everything. Call it when the machine's lifetime is bounded
by something narrower (route, level, modal, mount).
LiteStatechartError
Thrown for semantic violations:
send()from inside an exit action- Re-entry depth (32) exceeded
Configuration errors throw TypeError (shape) or RangeError (value).
Edge cases pinned down
These are the questions a code reviewer asks. The answers:
- Self-transitions (
a -> a). Rejected at compile withRangeError. Self-transitions have semantic ambiguity around whether exit/entry should fire; v1.0 deliberately punts the question. Roadmap item if a real consumer surfaces. - Two sends in the same tick. Each is processed independently. If the
first commits
idle -> busy, the second's dispatch is evaluated againstbusy, notidle. No implicit debouncing -- the consumer orchestrates if they want it. - Hydrate to a state with an entry action. Entry does NOT fire on hydrate. Entry fires on transition into a state; hydration is "load the machine in this state already." If you need entry-effect re-establishment, drive it from your application code after hydrate.
- Throwing entry / exit / transition action. The throw propagates out of
send(). State has already been committed (for entry/transition) -- the half-completed action's effects are the consumer's problem to clean up, just as with any synchronous code path. Exit throws before commit, leaving the machine in the previous state. - Guard with side effects. Discouraged but not prevented. The library contract is that guards are pure predicates; side-effectful guards work but make the machine harder to reason about. Use a transition action instead.
- Snapshot during a transition.
snapshot()is synchronous and reads the current state ID, which has either not been mutated yet (if called pre-commit from an exit action -- but exit can't call into the machine at all) or already reflects the new state (anywhere else). No race window. - Subscribing and unsubscribing in a tight loop. Both directions are steady-state allocation-free. Listener add appends to a pre-allocated array; removal is swap-with-last (O(1)). The pool grows with the array high-water mark and doesn't shrink, which is the right tradeoff for steady-state.
onTransitioncalled from inside another listener. The newly-added listener is NOT fired for the current transition. It fires from the next transition onward.- Calling
dispose()twice on the same listener handle. Idempotent. Second call is a no-op. hydrate.statethat exists but has no incoming transitions. Allowed. Sometimes you want to restore to a "stuck" state by design.
Benchmarks
Honest numbers, run on Node 22 with --expose-gc. Each scenario warms up,
then measures throughput and retained heap drift across the measured window.
| Scenario | Throughput | Per-op retention |
| ------------------------------------------------- | -------------------:| ----------------:|
| send() -- no actions, 6-state ring | 19.4M ops/s | ~0 B |
| send() -- entry + exit + transition actions | 12.5M ops/s | ~0 B |
| send() -- 50/50 guard pass/reject | 9.7M ops/s | ~0 B |
| matches() + can() inside an effect | 6.8M ops/s | ~0 B |
| onTransition fan-out -- 10 listeners | 7.3M ops/s | ~0 B |
| createStatechart -- 10 states × 20 events | ~30K compiles/s | n/a (compile) |
Compile cost is ~33 microseconds for a moderately-sized machine. Per-op retention "~0 B" means measured drift across 2M iterations is sub-byte per op -- well below GC noise floor, and across most scenarios the measured delta is negative (V8 reclaims warm-up working set).
The benchmark harness is in bench/bench.mjs.
Run it yourself:
npm run benchTesting strategy
Three tiers, all reproducible.
Tier 1 -- Behavior (unit tests, fast)
npm test runs the suite in test/, covering:
01-compile.test.mjs-- compile pipeline, state/event ID assignment, validation phases (allTypeError/RangeErrorpaths),hydrateround-trip, frozen handle guarantees.02-send.test.mjs-- send semantics, return values, unknown events, undefined transitions, strict type enforcement, payload propagation.03-actions.test.mjs-- entry / exit / transition action firing order (exit -> commit -> notify -> action -> entry), payload routing, per-edge action isolation.04-guards.test.mjs-- guard pass/fail, payload-aware rejection, no-side-effect-on-reject guarantee (no exit/entry/action/listener fires when a guard rejects).05-reentrant.test.mjs-- re-entry from entry and transition actions, depth-32 limit, exit-cannot-send guard, deeply nested cascades, listener-driven sends.06-state-signal.test.mjs--state()tracked read,state.peek()untracked,state.subscribe(fn)lifecycle,matches()/can()reactive re-evaluation insideeffect/computed,hydrate/snapshotround-trip.07-listeners.test.mjs-- listener fan-out, ordering, dispose idempotency, the mid-notify add/remove matrix (remove self, remove another, add new), guard-rejected and unknown-event no-fire guarantees.
npm testTier 2 -- Memory (allocation-free verification)
npm run test:gc runs the same suite plus
test/08-gc.test.mjs under --expose-gc:
- 1M
send()calls retain < 256 KB heap (measured: -16 KB; negative because warm-up working set is reclaimed) - 1M sends with full entry/exit actions: same envelope
matches()/can()1M-call loop: zero retentionsnapshot()100K-call loop: transient allocation, fully reclaimed- Listener add/remove cycle 100K times: zero retention
If any of these fail, something allocates in the hot path and we want to find it before publish.
npm run test:gcTier 3 -- Performance (measured throughput)
npm run bench runs the six-scenario benchmark and reports throughput +
heap delta per scenario. Output is plain text -- easy to copy into PRs and
changelogs.
npm run bench
npm run verify # test + test:gc + bench; the publish gateWhat this is not
- Not a hierarchical statechart. XState-style nested, parallel, and history states are out of scope. If you need them, use XState or another hierarchical engine; if hierarchical ever lands in this ecosystem, it will be a separate package, not behind a feature flag here. See ROADMAP.md for the reasoning.
- Not async. No
Promiseintegration, noawait, no microtask scheduling. Async orchestration belongs in a saga / coroutine layer. - Not a workflow engine. No retries, no exponential backoff, no
built-in scheduling. Compose with
lite-timeandlite-persist. - Not a persistence layer.
snapshot()+hydrateis the contract -- storage is the consumer's call (lite-persist,localStorage, IndexedDB, network, anything). - Not framework-specific. No React/Vue/Svelte hooks. Integrates
through
lite-signal'seffectandcomputed-- which can in turn be wrapped into whatever framework binding you need.
Ecosystem
A growing family of zero-GC, ESM-only, sub-2KB packages built on lite-signal.
All MIT, all by @zakkster.
Core
@zakkster/lite-signal-- the reactive graph this machine plugs into. Object-pooled, zero-GC, 32-bit modular versioning.
State, time, persistence
@zakkster/lite-store-- fine-grained reactivity for objects & arrays via Proxy. Direct mutation; lazy per-key signals.@zakkster/lite-resource-- async state as a signal. Race-safe commits, AbortSignal, SWR, optimistic mutate.@zakkster/lite-form-- headless reactive forms. Hoisted Zod/Yup, ~1.5M keystrokes/sec on 100-field forms.@zakkster/lite-router-- zero-GC sub-2KB SPA router. Path, query, and route matches as fine-grained signals.@zakkster/lite-persist-- debounced, coalesced localStorage/sessionStorage sync. Pairs naturally withsnapshot()+hydratehere.@zakkster/lite-time-- reactive, drift-corrected wall-clock cadence. Auto-pause when nothing observes.@zakkster/lite-raf-- zero-GC frame-rate scheduling for canvas/WebGL render loops.@zakkster/lite-room-- zero-GC peer presence and real-time room state over BroadcastChannel / Twitch PubSub. Primary consumer oflite-statechartfor peer-state lifecycle.
Browser and runtime support
Pure ES2020. Runs anywhere modern JavaScript runs.
| Target | Supported | | --------------------------------- | --------- | | Chrome / Edge (last 2 majors) | OK | | Firefox (last 2 majors) | OK | | Safari 14+ | OK | | Node.js 18+ | OK | | Bun | OK | | Twitch Extensions (1MB / 3s) | OK | | Cloudflare Workers | OK | | Deno | OK |
ESM-only. No CommonJS build -- modern bundlers handle this; legacy consumers can wrap.
Integration recipes
Twitch Extension auth lifecycle (1.1+ wildcard + final + ctx.signal)
import { createStatechart } from "@zakkster/lite-statechart";
import { effect } from "@zakkster/lite-signal";
const auth = createStatechart({
initial: "anonymous",
states: {
"*": { on: { CLOSE: "closed", FATAL: "error" } },
anonymous: { on: { JWT_RECEIVED: "validating" } },
validating: {
entry: async (payload, ctx) => {
try {
const res = await fetch("/ebs/verify", {
headers: { Authorization: payload.jwt },
signal: ctx.signal // cancels if we leave
});
if (res.ok) auth.send("VALID");
else if (res.status < 500) auth.send("INVALID");
else auth.send("FATAL");
} catch (e) {
if (e.name !== "AbortError") auth.send("FATAL", e);
}
},
on: { VALID: "authenticated", INVALID: "anonymous" }
},
authenticated: {
entry: scheduleRefresh,
exit: cancelRefresh,
on: { JWT_EXPIRING: "validating",
JWT_RECEIVED: "validating",
VISIBILITY_HIDDEN: "suspended",
LOGOUT: "anonymous" }
},
suspended: { on: { VISIBILITY_SHOWN: "authenticated",
JWT_EXPIRING: "validating" } },
closed: { final: true, entry: () => log("session closed") },
error: { final: true, entry: () => log("session fatal") }
}
});
Twitch.ext.onAuthorized((info) => auth.send("JWT_RECEIVED", info));
effect(() => auth.matches("authenticated") ? renderApp() : renderLogin());Three 1.1 features at work:
- Wildcard
"*"-- CLOSE and FATAL are defined once and apply across every non-final state. The "30 transitions across 6 states" form collapses to ~12. final: true--closedanderrorare terminal but observable;auth.state.peek()still returns"closed"so the goodbye screen renders until the consumer explicitly callsauth.dispose().ctx.signal-- if the machine leavesvalidating(e.g. CLOSE or a competing JWT_RECEIVED), the fetch in flight aborts, the catch seesAbortError, and no stale VALID/INVALID/FATAL event fires. No manual AbortController bookkeeping in the consumer.
The cascading send() from validating.entry is the canonical
"validate-and-route" pattern -- legal because entry actions are permitted
to re-enter.
lite-room peer presence
import { createStatechart } from "@zakkster/lite-statechart";
const presence = createStatechart({
initial: "disconnected",
states: {
disconnected: { on: { CONNECT: "connecting" } },
connecting: { entry: openTransport,
on: { OPEN: "syncing", FAIL: "error" } },
syncing: { entry: runHandshake,
on: { READY: "live", FAIL: "error" } },
live: { on: { CLOSE: "disconnected", ERROR: "error" } },
error: { entry: scheduleBackoff,
on: { RETRY: "connecting", GIVE_UP: "disconnected" } }
}
});Each entry side-effect is a function the consumer owns -- the machine just sequences them.
Persistence via lite-persist
import { persist } from "@zakkster/lite-persist";
machine.onTransition(() => persist.set("auth-snapshot", machine.snapshot()));
// On boot:
const m = createStatechart({
initial: "anonymous",
states: { /* ... */ },
hydrate: persist.get("auth-snapshot")
});The machine itself depends on nothing but lite-signal. Routing snapshots
to storage is the consumer's call.
Multi-machine composition
const auth = createStatechart({ /* ... */ });
const presence = createStatechart({ /* ... */ });
// Cross-machine reaction
effect(() => {
if (auth.matches("authenticated") && presence.matches("disconnected")) {
presence.send("CONNECT");
}
});Composition lives in userland. No actors, no parent/child, no spawn API --
just signals composing through lite-signal's reactive graph.
FAQ
Why no async?
Async state machines are a different shape -- they need cancellation,
retries, race-safety, and an effect runtime. That's saga / coroutine
territory. This package stays synchronous so the dispatch contract is
trivially auditable: every send() either committed or didn't, before
control returns.
Why no self-transitions in v1.0?
They're not hard to add -- they're hard to add unambiguously. Does
a -> a fire exit then entry? Just entry? Neither? Different state-machine
libraries answer differently; rather than pick wrong I deferred the question
to v1.1 with consumer input.
Why 65534 states?
The Uint16Array transition table reserves 0xFFFF as the NO_TRANSITION
sentinel. In practice, machines beyond a few dozen states usually want
hierarchical decomposition, which is out of scope here. If you genuinely
hit the cap, the machine has outgrown a flat FSM.
Why a fixed transition table?
Same reason lite-signal pre-allocates: predictable cost. Adding states
or transitions after compile would require resizing the Uint16Array,
re-indexing all integer IDs, and invalidating any cached lookups. The
trade-off -- recompile on shape change, allocate nothing in steady state --
is the right one for this library's target use cases.
Why no microtask scheduler?
Same reason lite-signal doesn't have one: causal opacity. When
machine.send(x) returns, you should know whether the transition committed,
without needing to await a microtask. Synchronous dispatch is the contract.
Why isn't state() writable?
The machine owns the state. Allowing external writes would bypass
exit/entry/listener notification, which is a footgun. Use send().
npm scripts
npm test # behavior suite, ~1s
npm run test:gc # zero-GC suite, requires --expose-gc, ~2s
npm run bench # six-scenario benchmark, ~3s
npm run verify # test + test:gc + bench; the publish gateLicense
MIT (c) Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal*lite-room*lite-time*lite-raf*lite-store*lite-router*lite-form*lite-persist
