@ersbeth/picomachine
v0.0.2
Published
Minimal statecharts library for JS/TS
Downloads
1
Maintainers
Readme
Picomachine
A tiny full-featured library for composable finite state machines and statecharts written in TypeScript:
- enter/exit/transition hooks for side effects
- nested states
- parallel states
- transient states
- multiple transitions
- guarded transitions
- delayed transitions
- async (promise-based) states
- history
- child/parent communication
- splitting and composition
@ersbeth/picomachine
aims at providing a seamless developper experience. The API is carefully crafted to be legible and allows easy splitting and composition of statemachines.
The library runs both in browser and on Node.js
Installation
npm install @ersbeth/picomachine
Usage
Finite state machine
Basic example
Here is a simple toggle machine. Comments show what is logged in the console.
import { StateMachine } from "@ersbeth/picomachine"
// Machine structure definition
const machine = new StateMachine("root", {
type: "nested",
initial: 'OFF', // initial state
states: { // keys are states names
ON: {
events: { // keys are events names
"TOGGLE": { target: "OFF" }
}
},
OFF: {
events:
{
"TOGGLE": { target: "ON" }
}
}
}
});
// Add listener
machine.status.onChanged((status) => console.log(status))
// Start machine
machine.start();
// -> [ 'root' ]
// -> [ 'root.OFF' ]
machine.send('TOGGLE');
// -> [ 'root.ON' ]
machine.send('TOGGLE');
// -> [ 'root.OFF' ]
Actions
Actions are functions which perform side-effects. FSM have several hooks where you can call actions:
- when entering a state
- when leaving a state
- when executing a transition
The hooks execution order is exit>transition>enter .
warning: actions should always be sync function. If you need to perform promised-based side-effect use activities instead (see below).
import { StateMachine } from "@ersbeth/picomachine"
const actions = {
enterON: () => console.log("enter ON"),
exitON: () => console.log("exit ON"),
enterOFF: () => console.log("enter OFF"),
exitOFF: () => console.log("exit OFF"),
toON: () => console.log("to ON"),
toOFF: () => console.log("to OFF")
}
const machine = new StateMachine("root", {
type: "nested",
initial: 'OFF',
states: {
ON: {
enter: actions.enterON, // enter actions (can be an array)
exit: actions.exitON, // exit actions (can be an array)
events: {
"TOGGLE": { target: "OFF", actions: actions.toOFF } // transition actions (can be an array)
}
},
OFF: {
enter: actions.enterOFF,
exit: actions.exitOFF,
events:
{
"TOGGLE": { target: "ON", actions: actions.toON }
}
}
}
});
// Add listener
machine.status.onChanged((status) => console.log(status))
// Start machine
machine.start();
// [ 'root' ]
// enter OFF
// [ 'root.OFF' ]
machine.send('TOGGLE');
// exit OFF
// to ON
// enter ON
// [ 'root.ON' ]
machine.send('TOGGLE');
// exit ON
// to OFF
// enter OFF
// [ 'root.OFF' ]
It is possible to send a data object with an event to the state machine :
machine.send('EVENT', someData)
This data object will be passed as parameter to the actions of the triggered transition where you can use it :
events: {
EVENT: {target: "SOMEWHERE", actions: (data) => doStuff(data)}
}
Self transitions
It is possible to transit from a node to itself. There are two types of self-transtions:
- external : it will trigger enter/exit hooks. For this use "self" as target.
- internal: it will not trigger enter/exit hooks. For this use "internal" as target
Guarded transitions
You can add guards to transitions so that they trigger only under certain circumstances. For example here when going out we take an umbrella if it's raining or a hat if it's hot:
import { StateMachine } from "@ersbeth/picomachine"
const guards = {
isRaining: () => true, // return something that depends on the context
isHot: () => false
}
const machine = new StateMachine("root", {
type: "nested",
initial: 'GOING_OUT',
states: {
GOING_OUT: {
events: {
"CHECK_WEATHER": [
{ target: "TAKING_UMBRELLA", guard: guards.isRaining }, // guard is true so we take this one
{ target: "TAKING_HAT", guard: guards.isHot }
]
}
},
TAKING_HAT: {},
TAKING_UMBRELLA: {},
}
});
machine.status.onChanged((status) => console.log(status))
machine.start();
// [ 'root' ]
// [ 'root.GOING_OUT' ]
machine.send('CHECK_WEATHER');
// [ 'root.TAKING_UMBRELLA' ]
Transitions are evaluated in the same order as they are declared in the array. The first whith a true guard is taken, and the other are ignored.
Delayed transitions
The execution of a transition can be delayed by adding a delay
field to it:
...
events: {
MY_EVENT: { target: "SOMEWHERE", delay: 500}
}
...
if we send "MY_ENVENT" to the state machine, the transition will execute after 500ms.
- if the transition is guarded, the guard is evaluated immediately.
- if the state machine exits the state before the end of the delay then the transition is canceled.
Transient transitions
A transient transition is automatically triggered on enter. These transitions are useful when combined with guards. Use the always
field to declare them:
...
MY_STATE: {
always: {target: "SOMEWHERE"}
}
...
A transient transition is always executed after the enter actions.
Nested States
Basic example
Just add a states
field with substates and an initial
state. Here is a basic example of a player machine. The player can be toggled ON/OFF, and when it's ON it can be PLAYING or PAUSED :
import { StateMachine } from "@ersbeth/picomachine"
const actions = {
enterON: () => console.log("enter ON"),
exitON: () => console.log("exit ON"),
enterOFF: () => console.log("enter OFF"),
exitOFF: () => console.log("exit OFF"),
enterPLAYING: () => console.log("enter PLAYING"),
exitPLAYING: () => console.log("exit PLAYING"),
enterPAUSED: () => console.log("enter PAUSED"),
exitPAUSED: () => console.log("exit PAUSED"),
toON: () => console.log("to ON"),
toOFF: () => console.log("to OFF"),
play: () => console.log("play"),
pause: () => console.log("pause"),
}
const machine = new StateMachine("root", {
type: "nested",
initial: 'OFF',
states: {
ON: {
type: "nested",
enter: actions.enterON,
exit: actions.exitON,
events: {
"TOGGLE": { target: "OFF", actions: actions.toOFF }
},
initial: "PLAYING",
states: {
PLAYING: {
enter: actions.enterPLAYING,
exit: actions.exitPLAYING,
events: {
"PAUSE": { target: "PAUSED", actions: actions.pause }
}
},
PAUSED: {
enter: actions.enterPAUSED,
exit: actions.exitPAUSED,
events: {
"PLAY": { target: "PLAYING", actions: actions.play }
}
}
}
},
OFF: {
enter: actions.enterOFF,
exit: actions.exitOFF,
events:
{
"TOGGLE": { target: "ON", actions: actions.toON }
}
}
}
});
// Add listener
machine.status.onChanged((status) => console.log(status))
// Start machine
machine.start();
// [ 'root' ]
// enter OFF
// [ 'root.OFF' ]
machine.send('TOGGLE');
// exit OFF
// to ON
// enter ON
// [ 'root.ON' ]
// enter PLAYING
// [ 'root.ON.PLAYING' ]
machine.send('PAUSE');
// exit PLAYING
// pause
// enter PAUSED
// [ 'root.ON.PAUSED' ]
machine.send('TOGGLE');
// exit PAUSED
// exit ON
// to OFF
// enter OFF
// [ 'root.OFF' ]
Final states
Final states are declared with type="error"
or type="done"
. When entering this states, the transition "error" or "done" is triggered on the parent. Here is an example of a machine which retries a computation until it gets a result:
import { StateMachine } from "@ersbeth/picomachine"
const machine = new StateMachine("root", {
type: "nested",
initial: 'COMPUTE',
states: {
COMPUTE: {
type: 'nested',
done: { target: "RESULT" },
error: { target: "self" }, // will re-trigger computations until we get a result
initial: "CALCULATE",
states: {
CALCULATE: {
events: {
"SUCCEED": { target: "DONE" },
"FAIL": { target: "ERROR" },
}
},
ERROR: { type: "error" },
DONE: { type: "done" },
}
},
RESULT: {}
}
});
machine.status.onChanged((status) => console.log(status))
machine.start();
// [ 'root' ]
// [ 'root.COMPUTE' ]
// [ 'root.COMPUTE.CALCULATE' ]
machine.send('SUCCEED');
// [ 'root.COMPUTE.DONE' ]
// [ 'root.RESULT' ]
It is possible to have multiple error and done states with custom names :
// default final states
ERROR:{}
DONE:{}
// custom final states
MY_ERROR: {
type: "error"
}
MY_DONE: {
type: "done"
}
The done
and error
transition are called with the name of the reached final state as argument.
Such In case of multiple final/error states you can use guarded transitions (see below) to decide what to do on the parent:
PARENT: {
done: [
{target: "OUTPUT_1", guard: (data)=> data=="MY_DONE_1"},
{target: "OUTPUT_2", guard: (data)=> data=="MY_DONE_2"},
]
...
}
History state
History can be activated in a state. It will keep track of the last active substate before quiting the state. When entering back the state the machine will enter the substate kept in history instead of the initial state. Let's take a look back at our previous player example. Now with history enabled when we switch on the player it will go to the last state where it was before:
import { StateMachine } from "@ersbeth/picomachine"
const machine = new StateMachine("root", {
type: "nested",
initial: 'OFF',
states: {
ON: {
type: "nested",
history: true, // history activation
events: {
"TOGGLE": { target: "OFF" }
},
initial: "PLAYING",
states: {
PLAYING: {
events: {
"PAUSE": { target: "PAUSED" }
}
},
PAUSED: {
events: {
"PLAY": { target: "PLAYING" }
}
}
}
},
OFF: {
events:
{
"TOGGLE": { target: "ON" }
}
}
}
});
machine.status.onChanged((status) => console.log(status))
machine.start();
// [ 'root' ]
// [ 'root.OFF' ]
machine.send('TOGGLE');
// [ 'root.ON' ]
// [ 'root.ON.PLAYING' ]
machine.send('PAUSE');
// [ 'root.ON.PAUSED' ]
machine.send('TOGGLE');
// [ 'root.OFF' ]
machine.send('TOGGLE');
// [ 'root.ON' ]
// [ 'root.ON.PAUSED' ] Here without history we would have been in PLAYING state
Parallel states
Basic example
To make a parrallel state you must activate the parallel
flag. Here is an example of a styling machine which manages bold/underline/italics in parallel :
import { StateMachine } from "@ersbeth/picomachine"
const machine = new StateMachine("root", {
type: "parallel",
states: {
BOLD: {
type: "nested",
initial: 'OFF',
states: {
ON: {
events: {
"TOGGLE_BOLD": { target: "OFF" }
}
},
OFF: {
events: {
"TOGGLE_BOLD": { target: "ON" }
}
}
}
},
UNDERLINE: {
type: "nested",
initial: 'OFF',
states: {
ON: {
events: {
"TOGGLE_UNDERLINE": { target: "OFF" }
}
},
OFF: {
events: {
"TOGGLE_UNDERLINE": { target: "ON" }
}
}
}
},
ITALICS: {
type: "nested",
initial: 'OFF',
states: {
ON: {
events: {
"TOGGLE_ITALICS": { target: "OFF" }
}
},
OFF: {
events: {
"TOGGLE_ITALICS": { target: "ON" }
}
}
}
},
}
});
machine.status.onChanged((status) => console.log(status))
machine.start();
// [ 'root.BOLD', 'root.UNDERLINE', 'root.ITALICS' ]
// [ 'root.BOLD.OFF', 'root.UNDERLINE', 'root.ITALICS' ]
// [ 'root.BOLD.OFF', 'root.UNDERLINE.OFF', 'root.ITALICS' ]
// [ 'root.BOLD.OFF', 'root.UNDERLINE.OFF', 'root.ITALICS.OFF' ]
machine.send('TOGGLE_BOLD');
// [ 'root.BOLD.ON', 'root.UNDERLINE.OFF', 'root.ITALICS.OFF' ]
machine.send('TOGGLE_UNDERLINE');
// [ 'root.BOLD.ON', 'root.UNDERLINE.OFF', 'root.ITALICS.OFF' ]
machine.send('TOGGLE_ITALICS');
// [ 'root.BOLD.ON', 'root.UNDERLINE.ON', 'root.ITALICS.ON' ]
Final states
There isn't final states in a parrallel state, but substates can have final states inside them.
- the
done
transition is triggered when all the substates wich have final states are done - the
error
transition is triggered when one of the substate which have error states is in error.
Child/Parent communication
Any state can raise an event on its parent trough an action. This is especially useful to allow communication between children of a parrallel state. The raised event is added to the javascript event queue, so that the current transition can end up before starting a new one.
Here is an example of parrallel children communication:
import { StateMachine } from "@ersbeth/picomachine"
const actions = {
doStuffB: () => console.log("Do stuff B"),
}
const machine = new StateMachine("root", {
type: "parallel",
states: {
STATE_A: {
events: {
DO_STUFF_A: { target: "self", actions: "DO_STUFF_B" }
}
},
STATE_B: {
events: {
DO_STUFF_B: { target: "self", actions: actions.doStuffB },
}
},
}
});
machine.status.onChanged((status) => console.log(status))
machine.start();
// ['root.STATE_A', 'root.STATE_B']
machine.send('DO_STUFF_A');
// [ 'root.STATE_A', 'root.STATE_B' ]
// Do stuff B
// [ 'root.STATE_A', 'root.STATE_B' ]
Activity States
You will often have to perform side-effects with are promised-based (eg. fetching data from the network). We call these side-effects "activities". They can be managed with an activity state, which has built-in error and done hooks that are triggered when the promise resolves. Let's see our computation example with a promised-base function:
import { StateMachine } from "@ersbeth/picomachine"
const activities = {
calculate: () => Promise.resolve()// return instead a promise whose resolution depends on the context
}
const machine = new StateMachine("root", {
type: "nested",
initial: 'COMPUTING',
states: {
COMPUTING: {
type: "async",
done: { target: "RESULT" },
error: { target: "self" }, // will re-trigger computations until we get a result
activity: activities.calculate // here we declare our activity
},
RESULT: {}
}
});
machine.status.onChanged((status) => console.log(status))
machine.start();
// [ 'root' ]
// [ 'root.COMPUTING' ]
// [ 'root.RESULT' ]
Composition
The structure of a statemachine can be spread across different nodes, either in the same file or in different files. This brings a better modularity and allows to reuse some parts of your machine in different places. To split a machine, use the src
field.
// these nodes could be defined in another file and imported here
const stateA = new StateMachine("A", {...})
const stateB = new StateMachine("B", {...})
// combine them under the root node
const machine = new StateMachine("root", {
states:{
A:{
src: stateA, // here you indicate the source node
},
B:{
src: stateB,
}
}
})
You can define transitions and actions either in the parent node or in the child node or event in both: everything will be merged.
warning: in the child node you can only define self-transitions as the siblings are not known. you must define siblings transitions on the parent node.
Acknowledgment
This library is heavily inspired by xstate for the API, but the implementation is completely different and much more minimal. If you need more feature than what we provide, go for xstate it's awesome !
License
MIT