nano-statechart
v0.1.3
Published
Lightweight, first-principles statechart engine in TypeScript
Downloads
364
Readme
nano-statechart
A lightweight, first-principles statechart engine in TypeScript.
nano-statechart provides a powerful pure-data state machine implementation, heavily inspired by the SCXML specification and xstate, but designed to be tiny, fast, and dependency-free. It supports advanced features like hierarchical states, parallel regions, history states, contextual data, guards, and side effects.
Features
- Pure Data Definitions: Define your machines as pure data structures type-checked by TypeScript.
- Pure Execution: The core
executefunction is referentially transparent. It takes a state, event, context, and history, and returns the next state and effects without mutations. - Stateful Service Wrapper: Use
createServicefor an imperative, event-driven API similar to traditional statechart libraries. - Hierarchical States: Nest states within states, defining entry/exit paths recursively.
- Parallel Regions: Execute orthogonal state machines concurrently.
- History States: Remember the last active child state and resume seamlessly.
- Guards & Actions: Powerful conditional routing and side effect management.
- Lightweight: Tiny footprint. Zero external runtime dependencies.
Installation
npm install nano-statechartQuick Start
1. Using the Stateful Service (Imperative API)
The easiest way to use nano-statechart is with the createService wrapper. It maintains the current state internally and executes effects for you.
import { createService, MachineDefinition } from "nano-statechart";
type LightState = "Green" | "Yellow" | "Red";
type LightEvent = { type: "TIMER" };
type LightFx = { type: "log"; message: string };
const lightMachine: MachineDefinition<LightState, LightEvent, LightFx> = {
initial: "Green",
context: undefined,
states: {
Green: {
on: { TIMER: { target: "Yellow", effects: [{ type: "log", message: "Going yellow" }] } },
entry: [{ type: "log", message: "Entered Green" }]
},
Yellow: {
on: { TIMER: { target: "Red" } },
},
Red: {
on: { TIMER: { target: "Green" } },
},
},
};
// Create a service, providing an optional effect handler
const service = createService(lightMachine, (effect) => {
if (effect.type === "log") {
console.log(effect.message);
}
});
service.subscribe((result) => {
console.log("Transitioned to:", result.next);
});
// Sends event, updates state internally, and calls the effect handler
service.send({ type: "TIMER" });
// Logs:
// "Going yellow"
// "Transitioned to: Yellow"2. Using Pure Execution
If you prefer managing state yourself (e.g., in a React generic reducer or a Redux store), you can use the pure execute function.
import { execute, getInitialState } from "nano-statechart";
// Assume `lightMachine` from the previous example
let currentState = getInitialState(lightMachine);
const result = execute(lightMachine, currentState, { type: "TIMER" }, undefined);
console.log(result.next); // "Yellow"
console.log(result.effects); // [{ type: "log", message: "Going yellow" }]
// You must track the next state yourself
currentState = result.next;Core Concepts
Context (Extended State)
State machines can hold quantitative data in their context. Updates to the context happen via reduce functions on transitions.
type ATMState = "Idle" | "Active" | "Locked";
type ATMEvent = { type: "WRONG_PIN" };
type ATMCtx = { attempts: number };
const atmMachine: MachineDefinition<ATMState, ATMEvent, never, ATMCtx> = {
initial: "Idle",
context: { attempts: 0 },
states: {
Idle: {
on: {
WRONG_PIN: [
// Triggered if attempts is already 2 (this is the 3rd attempt)
{
target: "Locked",
guard: (e, ctx) => ctx.attempts >= 2,
reduce: (ctx) => ({ attempts: ctx.attempts + 1 }),
},
// Fallback transition
{
target: "Active",
reduce: (ctx) => ({ attempts: ctx.attempts + 1 }),
},
]
}
},
// ... Active and Locked states
}
}Guards
Transitions can be conditional. By providing an array of transition objects, the machine will evaluate them in order and pick the first one where the guard returns true. If no guard is specified, it returns true.
on: {
ENTER_PIN: [
{ target: "Unlocked", guard: (e, ctx) => e.pin === ctx.correctPin },
{ target: "Locked" } // fallback
]
}Hierarchy (Nested States)
State machines can explode in complexity if you have to redefine standard transitions (like "LOGOUT") on every single state.
Hierarchical (nested) states solve this by allowing a parent state to handle shared events seamlessly for all its children. When a parent state is targeted, it automatically delegates to its defined initial child state.
You can define nested states naturally using a recursive states object:
type AppState = "LoggedOut" | "LoggedIn" | "LoggedIn.Dashboard" | "LoggedIn.Settings";
type AppEvent = { type: "LOGIN" } | { type: "LOGOUT" } | { type: "GO_SETTINGS" } | { type: "GO_DASHBOARD" };
const appMachine: MachineDefinition<AppState, AppEvent, string> = {
initial: "LoggedOut",
states: {
LoggedOut: {
on: { LOGIN: { target: "LoggedIn" } }
},
// Parent state:
LoggedIn: {
initial: "Dashboard", // Automatically enters Dashboard
entry: ["welcome:user"],
exit: ["goodbye:user"],
on: {
// Any child state receiving "LOGOUT" will trigger this transition
LOGOUT: { target: "LoggedOut" }
},
states: {
Dashboard: {
on: { GO_SETTINGS: { target: "LoggedIn.Settings" } }
},
Settings: {
on: { GO_DASHBOARD: { target: "LoggedIn.Dashboard" } }
}
}
}
}
}If you evaluate this configuration:
- Triggering
LOGINfromLoggedOutwill executewelcome:userand land automatically theLoggedIn.Dashboardleaf state. - Triggering
GO_SETTINGSfromLoggedIn.Dashboardwill transition cleanly toLoggedIn.Settings. - Triggering
LOGOUTfrom eitherLoggedIn.DashboardorLoggedIn.Settingswill bubble up, executinggoodbye:user, and land safely back atLoggedOut.
Note: Our engine supports both fully nested states objects as shown here, as well as flat-map structures referencing a string parent property.
History States
Sometimes you want to return to the child state you were last in rather than the default initial state. You can transition to [ParentState].$history.
// From a "Paused" state inside a media player
on: {
RESUME: { target: "Playing.$history" } // Returns to "Playing.Song" or "Playing.Podcast"
}Note: The history mapping is maintained automatically by the execute function and the Service wrapper.
Parallel Regions
Orthogonal states (e.g., controlling background music and air conditioning simultaneously) can be managed with executeParallel.
import { executeParallel, ParallelRegion } from "nano-statechart";
const regions: ParallelRegion<CarEvent, string>[] = [
{ definition: acMachine, state: "Off", context: undefined },
{ definition: musicMachine, state: "Stopped", context: undefined },
];
const result = executeParallel(regions, { type: "PLAY" });
// Result merges effects from all regions and updates local states.API Reference
Types
MachineEvent<Type>: Base event type ensuring your events have atypestring discriminant.MachineDefinition<S, E, Fx, Ctx>: The main configuration object.TransitionResult<S, Fx, Ctx>: Whatexecuteandservice.sendreturn:{ next: string, effects: Fx[], context: Ctx, history: HistoryMap }.
Methods
createService(def, effectHandler?)Creates an imperative service.send(event): Dispatches an event.getState()/getContext()/getHistory(): Getters.subscribe(listener): Listen for transitions. Returns an unsubscribe function.
execute(def, current, event, ctx, history?)The pure execution function. Calculates correct exit and entry paths up to the Least Common Ancestor, updates history, and merges effects.getInitialState(def)Resolves the actual starting leaf-state.executeParallel(regions, event)Fires the event into multiple parallel machines and merges the resulting states and effects.
License
MIT
