npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.

npm version sponsor Zero-GC Send npm bundle size npm bundle size npm downloads npm total downloads lite-signal peer TypeScript Dependencies license

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-signal
import { 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

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:

  1. Ad-hoc switch statements. Free, but every machine ends up with its own bug shape: forgotten transitions, missing exit cleanup, ambiguous state under cascading events.
  2. 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:

  1. Zero allocation in the hot path. A 60-fps Twitch overlay can't tolerate GC pauses. send() touches no heap.
  2. Synchronous and deterministic. Every send() either commits a transition synchronously or returns false. No microtask scheduling.
  3. 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. true if committed, false if 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 with hydrate to restore.
  • machine.onTransition(fn) -- post-commit listener with safe add/remove semantics under mid-notify mutation.
  • LiteStatechartError -- thrown for semantic violations (exit calling send(), re-entry depth exceeded). Configuration errors throw TypeError / 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:

  1. eventMap.get(eventName) -- string-keyed Map, monomorphic
  2. transitions[currentStateId * numEvents + eventId] -- integer-indexed TypedArray read, the fastest read shape V8 supports
  3. 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 --> Phase4

All validation surfaces at createStatechart(), not at first send():

  • TypeError -- wrong shape (non-string names, non-function actions, non-object on, etc.)
  • RangeError -- unresolvable references, duplicates, self-transitions, out-of-range counts (> 65534 states)

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 true

Phase 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.entry checks a predicate and immediately sends VALID or INVALID.
  • 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 loud LiteStatechartError the 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 entry

Wildcard 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() and can() return false while in a final state.
  • The state signal, onTransition, matches(), subscribe(), and snapshot() remain observable. Consumers can render a "closed" or "error" screen against state.peek() indefinitely.
  • dispose() is orthogonal -- the consumer chooses when to release the machine's pool slot.
  • A final state may have an entry action; it must NOT have on (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): boolean

true 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 Dispose

machine.matches / machine.can

machine.matches(name: string): boolean   // tracked, throws on non-string
machine.can(event: string):    boolean   // tracked, throws on non-string

matches 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();        // idempotent

Returns 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() return false (silent no-op)
  • state() / state.peek() / snapshot() / matches() return the state-name snapshotted at dispose-time
  • state.subscribe(fn) returns a no-op dispose without registering
  • onTransition(fn) returns a no-op dispose without registering
  • Existing onTransition subscribers 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 with RangeError. 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 against busy, not idle. 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.
  • onTransition called 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.state that 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 bench

Testing 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 (all TypeError / RangeError paths), hydrate round-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 inside effect/computed, hydrate/snapshot round-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 test

Tier 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 retention
  • snapshot() 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:gc

Tier 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 gate

What 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 Promise integration, no await, 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-time and lite-persist.
  • Not a persistence layer. snapshot() + hydrate is 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's effect and computed -- 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 with snapshot() + hydrate here.
  • @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 of lite-statechart for 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 -- closed and error are terminal but observable; auth.state.peek() still returns "closed" so the goodbye screen renders until the consumer explicitly calls auth.dispose().
  • ctx.signal -- if the machine leaves validating (e.g. CLOSE or a competing JWT_RECEIVED), the fetch in flight aborts, the catch sees AbortError, 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 gate

License

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