Deterministic state management for applications that can't afford surprises.
A zero-dependency reactive library where ordering is declared, errors never abort, and every state change leaves a receipt. 3.2KB minified + gzipped.
State is a flat map of named fields. The only way to change it is by firing an event. A subscriber receives the event payload and returns the fields it wants to update.
import { field, event, createSystem, Ok } from 'mechflow'
const balance = field('balance', { default: 0 })
const deposited = event<{ amount: number }>('deposited')
const sys = createSystem({ fields: [balance], events: [deposited] })
// Add the deposit to the balance — inline lambda for simple one-liner
sys.subscribe(deposited, ctx =>
Ok({ balance: ctx.chain.current.balance + ctx.payload.amount })
)
// Same intent, written as a named function: read the current balance from
// the chain, compute the sum, return the new balance as a delta
function addToBalance(ctx) {
const newBalance = ctx.chain.current.balance + ctx.payload.amount
return Ok({ balance: newBalance })
}
sys.subscribe(deposited, addToBalance)
const r = sys.fire(deposited, { amount: 50 })
console.log(r.state) // { balance: 50 }One field. One event. One subscriber. Fire → delta → new state. That's the core loop.
Add a second subscriber that needs to run after the first. Declare the relationship with .after():
import { field, event, createSystem, Ok } from 'mechflow'
const balance = field('balance', { default: 100 })
const flag = field('flag', { default: '' as string })
const withdrew = event<{ amount: number }>('withdrew')
const sys = createSystem({ fields: [balance, flag], events: [withdrew] })
// Deduct the withdrawal amount from the balance — inline lambda returns
// the new balance as a delta
sys.subscribe(withdrew, ctx =>
Ok({ balance: ctx.chain.current.balance - ctx.payload.amount })
).id('deduct')
// Determine account status after the deduction runs: read the updated
// balance from the chain, check if it went negative, then set the flag
function checkOverdrawn(ctx) {
const status = ctx.chain.current.balance < 0 ? 'overdrawn' : 'clear'
return Ok({ flag: status })
}
sys.subscribe(withdrew, checkOverdrawn).id('check').after('deduct')
const r = sys.fire(withdrew, { amount: 150 })
console.log(r.state) // { balance: -50, flag: 'overdrawn' }The check subscriber sees the balance that deduct produced. It can also reach back with ctx.chain.find('deduct') to inspect any prior subscriber's delta or error.
The system validates the full ordering graph at registration time. If you create a cycle, it throws immediately with the cycle path — not at runtime.
A subscriber returns Err(error) instead of Ok(delta) to signal failure. The error is recorded on the chain. The tick continues — all remaining subscribers still run.
import { field, event, createSystem, Ok, Err } from 'mechflow'
const balance = field('balance', { default: 100 })
const log = field('log', { default: '' as string })
const withdrew = event<{ amount: number }>('withdrew')
const sys = createSystem({ fields: [balance, log], events: [withdrew] })
// Validate the withdrawal: return Err if the amount exceeds the current
// balance, otherwise return Ok with the new balance
sys.subscribe(withdrew, ctx =>
ctx.payload.amount > ctx.chain.current.balance
? Err(new Error('insufficient funds'))
: Ok({ balance: ctx.chain.current.balance - ctx.payload.amount })
).id('deduct')
// Record the audit trail: look up deduct's result in the chain, check
// whether it errored, then set the log entry accordingly
function audit(ctx) {
const prev = ctx.chain.find('deduct')
const entry = prev?.error ? `failed: ${prev.error.message}` : 'approved'
return Ok({ log: entry })
}
sys.subscribe(withdrew, audit).id('audit').after('deduct')
const ok = sys.fire(withdrew, { amount: 50 })
console.log(ok.state) // { balance: 50, log: 'approved' }
console.log(ok.chain.find('deduct')?.error) // undefined
const fail = sys.fire(withdrew, { amount: 999 })
console.log(fail.state) // { balance: 50, log: 'failed: insufficient funds' }
console.log(fail.chain.find('deduct')?.error?.message) // 'insufficient funds'Notice: on the second fire, deduct returned Err, so balance stayed at 50. But audit still ran — it read the error from the chain and set log accordingly.
Every subscriber runs, regardless of prior errors. Errors are values, not exceptions. There is no corrupt state, no silent rollback, no partial update.
Every state library makes tradeoffs. Here's how MechFlow compares:
| Concern | Redux | MobX / Signals | Zustand | MechFlow |
|---|---|---|---|---|
| Subscriber ordering | Middleware chain only; reducer-to-reducer order is undefined | Not declared — depends on which computed was observed first | Not declared — hooks fire in component tree order | Declared with before/after |
| Error isolation | One reducer throws, the whole store is undefined | A computed throws, the reactive graph poisons | A subscriber throws, the whole update drops | Per-subscriber Result — failing never aborts the tick |
| Audit trail | DevTools only in development | None | None | Chain object is always produced — production-grade tracing |
| Dependency tracking | Manual mapStateToProps, selectors |
Implicit (getter interception) | Manual selectors, shallow compare | Explicit — subscribers declare inputs via the chain |
| Bundle size | ~12KB (RTK) | ~16KB | ~2KB | 8.3KB minified / 3.2KB gzipped |
| Runtime deps | Immer, Redux core, thunks etc. | None | None | Zero |
- You need multi-step processing of events where order matters (payment → fraud check → audit log)
- You want provable error resilience — a failed validation step shouldn't silently roll back a successful prior step, and it shouldn't prevent the audit step from running
- You want a production-grade audit trail — every tick produces a chain object you can log, inspect, or replay
- You want to trust correctness — the system validates the ordering graph at registration time; cycles throw with the full path before any state is touched
- You have a simple counter or toggle —
useStateis fine. MechFlow pays for ordering and error isolation you don't need yet. - You want fully automatic reactivity (MobX/Signals) — MechFlow requires you to declare what depends on what. That's deliberate overhead for correctness.
- You need routing, data fetching, or a build tool — MechFlow is state + view bindings only. Bring your own framework.
- You're shipping to IE11 — the bundle uses modern JavaScript with no polyfills.
Fields are named state slots with a default value. They define the shape of the system and enable full TypeScript inference:
const hp = field('hp', { default: 100 }) // type: Field<number, 'hp'>Events carry typed payloads. An interface defines the shape; event() wraps it:
interface DamageEvent { amount: number }
const damaged = event<DamageEvent>('damaged')Subscribers are functions that receive { chain, payload, tick, event } and return Result<Delta, Error>:
function applyDamage(ctx) {
return Ok({ hp: ctx.chain.current.hp - ctx.payload.amount })
}
sys.subscribe(damaged, applyDamage).id('apply-damage')The Chain is the ordered list of every subscriber's result within a tick:
chain.first— state when the event startedchain.current— state of the last successful subscriber (skips error links)chain.unsafeCurrent— state of the last subscriber regardlesschain.find(id)— find a subscriber's result by its declared id- Chain is iterable —
for (const link of chain)over the full history
A subscriber can inspect any prior subscriber's state, delta, or error via ctx.chain.find('some-subscriber-id').
Errors are never thrown — they're returned as values and recorded on the chain.
sys.subscribe(charged, ctx => {
return ctx.payload.amount > ctx.chain.current.creditLimit
? Err(new Error('card declined'))
: Ok({ balance: ctx.chain.current.balance - ctx.payload.amount })
}).id('authorize').before('receipt')
sys.subscribe(charged, ctx => {
const auth = ctx.chain.find('authorize')
return ctx.payload.amount > 10000 && !auth?.error
? Ok({ fraudFlag: true })
: Ok({ fraudFlag: false })
}).id('fraud-check').after('receipt')If authorize fails, balance is not changed, but fraud-check still runs — it can inspect the error and react accordingly. All subscribers run regardless of prior errors.
The state is always deterministic: errors leave the previous successful state in place. There is no partial update, no corrupt state, no silent rollback.
Order is declared at subscription time, not discovered at runtime:
sys.subscribe(event, handler).id('c').after('a').before('b')The system resolves the graph using Kahn's topological sort. If you introduce a cycle, it throws immediately at registration time — with the full cycle path — rather than failing silently at runtime.
Priority hints ('early' | 'late') provide soft ordering within the same topological layer. They never override hard before/after edges.
Declarative Web Component bindings with zero build step for templates:
<template id="status-bar">
<div>
<div mf-bind:style="width:{0}% | hpPercent" class="hp-fill"></div>
<span mf-text="hp"></span> / <span mf-text="hpMax"></span>
<div mf-toggle="bloodied">CRITICAL</div>
<button mf-on:click="takeDamage:5">Hurt</button>
</div>
</template>
<script type="module">
import { flow, useSystem } from 'mechflow'
import { system } from './game-state.js'
useSystem(system)
flow('status-bar', document.getElementById('status-bar'))
</script>Current binding attributes:
mf-text="fieldName"— setstextContentreactivelymf-bind:attr="template | field1, field2"— binds an attribute from one or more fields using{0},{1}positional referencesmf-toggle="fieldName"— toggleselement.hiddenbased on truthinessmf-on:event="handlerName:payload"— wires DOM events to system events with optional payload parsing
All attributes are resolved at runtime in connectedCallback. No build step required.
Tick throughput on commodity hardware (single thread, no JIT warmup, Deno 2.7):
| Subscribers | Boot (one-time) | Tick (post-boot) | Ticks within 16ms (60fps) |
|---|---|---|---|
| 10 | ~0.21ms | ~0.064ms | ~15,600 |
| 100 | ~1.78ms | ~0.12ms | ~8,300 |
| 500 | ~17.1ms | ~0.30ms | ~3,300 |
| 2000 | ~62.9ms | ~0.50ms | ~2,000 |
Boot runs once on the first fire() — it resolves subscriber ordering and builds the handler dispatch array. Subsequent ticks dispatch a flat array with a reusable context object, no Map lookups, no closures, and no class instantiation in the inner loop.
Bundle: 8.3KB minified, 3.2KB minified + gzipped (zero runtime dependencies).
npm install mechflowMechFlow is also available on GitHub Packages as @deveyus/mechflow for CI/CD workflows that authenticate via GitHub.
deno task build # → dist/mechflow.js (self-contained ESM bundle)
deno task build-testbed # → testbed/main.jsYou're considering letting a state management library into your application. You should know exactly what you're getting — and that it hasn't been tampered with between the author's keyboard and your node_modules.
Every commit and release tag in this repository is signed with a hardware security key. The key is registered on GitHub — every commit shows as Verified. The @deveyus/mechflow npm package is built and published from those signed commits via CI.
What this means for you:
- Chain of custody. Every change has a cryptographic signature proving who authored it and when. No anonymous commits. No squashed history hiding provenance.
- Tags are signed, not just commits. Release
v0.3.2is backed by the same key. You can verify that the published artifact matches the tagged source. - Verify locally:
git log --show-signatureThe signing key exists on a physical device in the maintainer's possession — not a file on disk, not a CI token. Physical presence is required to sign.
This project practices what it preaches about correctness. The same discipline applied to state ordering and error isolation is applied to the integrity of the code itself.
deno task test # Core unit tests
deno run src/e2e_test.ts # Chromium headless e2e| Doc | What it covers |
|---|---|
| docs/ABSTRACT.md | Core principles, tick model, architecture |
| docs/API.md | Full API reference |
| docs/TICK_LIFECYCLE.md | Walkthrough of a single tick |
| docs/ORDERING.md | Partial ordering, topological sort, priority |
| docs/VIEW_BINDING.md | Declarative mf-* binding attributes |
| docs/TYPE_SYSTEM.md | TypeScript integration and type inference |
| docs/DECISIONS.md | Architecture Decision Records |
EUPL-1.2 — see LICENSE.