gen-statem v1.3.1
StateM
Introduction
StateM is framework for Javascript state machines.
StateM is largely based on Erlang OTP’s gen_statem behavior.
State machines are ideal for programming tasks that involve a) a sequence of operations and b) logic that dictates which operation will be executed after the current one based, for example, on:
- data you’re managing
- inputs from the user
- responses from pending tasks
- timeouts
Typically, state machine implementations are structured as forest of trees, with:
- states as root nodes,
- inputs/transitions as intermediate nodes,
- handlers and actions as leaf nodes. Inputs are handled by walking down the tree from the root of node of current state.
Note: A tree specification can grow very quickly as the number of states and events increase.
In contrast, StateM uses:
- A flat list with composite keys of
event + state
; - Pattern matching on keys collapses the search space.
In StateM, state machines are specified as an ordered list of handlers keyed by Event x State
patterns. Events drive the state machine and are externally triggered or internally generated by the state machine. Pending events are tracked on a priority queue that preserves entry order. State machines can hold arbitrary data which is provided to, and can be mutated by, its event handlers.
Features
- Co-located code: Event, states, transitions and actions in one place.
- Inserted Events: Insert events from within the specification.
- State Entry Events: Automatically generates Entry events on state change.
- Timeouts: Install timeouts for state transitions, new events, or just plain timeouts.
Installation
Install with npm
:
npm install --save gen-statem
Usage
State machines implement the IStateMachine
interface.
Create a StateMachine
You can create a state machine by calling StateMachine's
constructor and passing it a list of handlers, the initial state and optional data.
Note: The state machine’s data type
TData
is a type argument (StateMachine<TData>
).
For example, the state machine below toggles states ONE
and TWO
on event next
.
let sm = new StateMachine<void>({
handlers: [
['cast#next#ONE', 'TWO'],
['cast#next#TWO', () => nextState('ONE')],
],
initialState: "ONE",
})
sm.startSM()
sm.on('stateChanged', (state, old, data, event) => {
console.log(`${old} --> ${state} on ${event ? event.toString() : ''}`)
})
sm.cast('next')
// ONE --> TWO on Cast@Low { context: 'next' }
sm.cast('next')
// TWO --> ONE on Cast@Low { context: 'next' }
Subclassing StateMachine
Extending the StateMachine
class lets you:
- Declare and implement a public API for your state machine (and wrap call/cast dispatch calls).
- Provide helper functions.
Note: Handler functions are called with
this
set to the state machine instance.
class PingPong extends StateMachine<void> {
handlers: Handlers<void> = [
['cast#next#ONE', 'TWO'],
['cast#next#TWO', 'ONE'],
]
initialState = "ONE"
// Define our public API
next() {
this.cast('next')
}
}
let sm = new PingPong()
sm.startSM()
sm.on('stateChanged', (state, old, data, event) => {
console.log(`${old} --> ${state} on ${event ? event.toString() : ''}`)
})
sm.next()
// ONE --> TWO on Cast@Low { context: 'next' }
sm.next()
// TWO --> ONE on Cast@Low { context: 'next' }
In the Browser
Fetch it from npm via unpkg:
<script src="https://unpkg.com/gen-statem/dist/umd/gen-statem.js"></script>
React State Management
StateM accepts a DataProxy
object to synchronize its internal data with external objects such as React components.
Terminology: State machine data == React component state
class App extends React.Component {
constructor() {
this.sm = new StateMachine( {
dataProxy: {
get: () => this.state,
set: ( data, state ) => this.setState( { ...data, currentState: state } ),
},
} )
}
}
Details
Routes
StateM uses the url path to parameterized route matching seen in express et. al.
Current Event Routes
The current event and current state are mapped to a route string as:
<current event>#<event context>#<current state>
For example:
Event
Event Context
Current State
Route
cast
"flip"
off
"cast#flip#off"
cast
{button: 2}
locked
"cast#button/2#off"
call
"getInfo"
one
"call/internalId#getInfo/2#one"
Event Handler Routes
In addition to string literals, handler routes can include:
- Parameter capture patterns (
:param
) capture up to the next/
,#
or the end of the route. - Splats (
*param
) capture from up to#
or the end of the route. - Parts of the route can be marked optional by wrapping in parenthesis. Optional parts can include parameter capture and splats.
For example:
cast#flip#:state
will match acast(flip)
event in any state and provide the current state as an argument (args.param
) to the handler.call/:from#getInfo#:state
will capture the callerId and state asargs.from
andargs.state
respectively."cast#button/:digit#locked
will capture a button press in thelocked
state and provide the digit value inargs.digit
."cast#*context#open
intercepts anycast
events in stateopen
regardless of the parameters passed when cast was invoked (note: the splat will be available asargs.context
).
Event Handlers
A key part of a state machine specification is the list of (route, handler) tuples:
(string | Array<string>, function | string | [string, string | number])
Note: Event handlers are specified as an array to preserve order (vs. objects, where propertyiteration order is arbitrary).
Multiple Routes to a Handler
When a route is specified as an array, it is treated as a boolean OR, i.e. if any route matches an incoming event route, the corresponding handler is invoked.
Handler Functions
Handler functions receive the following:
type HandlerOpts<TData> = {
args: { [k in string]: string },
current: State,
data: TData,
event: Event,
route: string
}
current
: the state machine’s current state.data
: the state machine’s current data.args
: any arguments or splats parsed from the incoming event’s route.event
: the actual incoming event.route
: the event’s route.
Handler functions can return:
ResultBuilder
a fluent builder forResults
.Result
- verbose, not recommended.void
- interpreted askeepStateAndData
string
Handlers
Instead of a handler function, you can provide a
state: string
- interpreted as a next state directive.[state: string, timeout: string | number]
- interpreted as a next state directive and event timeout.
Result Builder
StateM provides a fluent interface (ResultBuilder
) for specifying state transitions and actions in handler functions.
The following functions return a ResultBuilder
:
- keepState: Instructs the state machine to keep the current state (i.e. transition to the same state). Does not emit a state entry event.
- repeatState: Like
keepState
, but emits a state entry event. - nextState(state): Instructs the state machine to transition to the given
state
(can be the current state).
ResultBuilder Methods
ResultBuilders
provide the following chainable methods:
- data(spec): Instructs the state machine to mutate state data with the given
spec
. - eventTimeout(time): Starts the event timer which may result in a
EventTimeoutEvent
event if a new event is not received. - stateTimeout(time): Starts the state timer which may result in a
StateTimeoutEvent
event if a state transition does not occur. - timeout(time, name): Starts a generic timer with an optional name which may result in a
GenericTimeoutEvent
. Callingtimeout(name)
will cancel the event if it has not yet fired. - nextEvent(type, context, extra): Inserts an event of the given type at the front of the queue so that it is executed before pending events.
- internalEvent(context, extra): Like
nextEvent
, this method inserts anInternalEvent
event. - postpone: Instructs the state machine to postpone the current until the state changes at which point any postponed events are delivered before pending events.
- reply(from, msg): Instructs the state machine to reply to the sender with id
from
; the result of a prior invocation ofcall
.
Mutating State Machine Data
State machine data is mutated by calling the data
on ResultBuilder
with immutability-helper commands, including:
{$push: array}
push()
all the items inarray
on the target.{$unshift: array}
unshift()
all the items inarray
on the target.{$splice: array of arrays}
for each item inarrays
callsplice()
on the target with the parameters provided by the item. Note: The items in the array are applied sequentially, so the order matters. The indices of the target may change during the operation.* *{$set: any}
replace the target entirely.{$toggle: array of strings}
toggles a list of boolean fields from the target object.{$unset: array of strings}
remove the list of keys inarray
from the target object.{$merge: object}
merge the keys ofobject
with the target.{$apply: function}
passes in the current value to the function and updates it with the new returned value.{$add: array of objects}
add a value to aMap
orSet
. When adding to aSet
you pass in an array of objects to add, when adding to a Map, you pass in[key, value]
arrays like so:update(myMap, {$add: [['foo', 'bar'], ['baz', 'boo']]})
{$remove: array of strings}
remove the list of keys in array from aMap
orSet
.
Event Types
- CallEvent: Sends a event of type
call
to the state machine and returns a pending Promise.- Call
call(context, extra)
to emit. - Event handlers can reply with
reply()
action which resolves the pending Promise returned bycall()
. - Internally, call generates a
from
id to identify the caller. - The event route for call is:
call/<from>#<context>#<state>
.
- Call
- CastEvent: Sends a event of type
cast
to the state machine and returns without waiting for a result.- Call
cast(context, extra)
to emit. - The event route for cast is:
cast#<context>#<state>
.
- Call
- EnterEvent: Sends a
enter
event to the state machine.- Internally generated by the state machine on a state transition. If the state machine is transitioning to the same state,
enter
is emitted if the previous event handler returnsrepeatState
(and not for either ofkeepState
, ornextState(same state)
).
- Internally generated by the state machine on a state transition. If the state machine is transitioning to the same state,
- EventTimeoutEvent: Sends a
eventTimeout
event to the state machine.- Internally generated by the state machine when the eventTimeout timer fires.
- The eventTimeout timer is started by invoking
eventTimeout(timeout)
in a event handler. - The event route for
eventTimeout
is:eventTimeout#<context>#<state>
. - Can be cancelled by calling
eventTimeout()
without a timeout argument from an event handler.
- GenericTimeoutEvent: Sends a
genericTimeout
event to the state machine.- Internally generated by the state machine when the (optionally named) genericTimeout timer fires.
- A genericTimeout timer is started by invoking
genericTimeout(timeout [, name])
in a event handler. - The event route for
genericTimeout
is:genericTimeout#<context>#<state>
. - Can be cancelled by calling
genericTimeout([name])
without a timeout argument from an event handler.
- StateTimeoutEvent: Sends a
stateTimeout
event to the state machine.- Internally generated by the state machine when the stateTimeout timer fires.
- The stateTimeout timer is started by invoking
stateTimeout(timeout)
in a event handler. - The event route for
stateTimeout
is:stateTimeout#<context>#<state>
. - Can be cancelled by calling
stateTimeout()
without arguments from an event handler.
- InternalEvent: Sends a
internal
event to the state machine. This is a deliberately named event to let the state machine know that the event is internal.- Internally generated by invoking
internal(context, extra)
from a event handler. - The event route for
internal
is:internal#<context>#<state>
.
- Internally generated by invoking
Processing Events
The state machine looks for the first event handler whose key matches the incoming event x current state
, or, a catch-all handler.
The matched handler is invoked with the incoming event, route matching arguments, the current state machine state and data.
The result of the handler invocation can include a state transition directive and transition actions, which are immediately executed, potentially changing the state machine’s state, mutating the internal data as well as the event queue.
Complex State
Examples
See the examples
directory for more examples.
Toggle Button With Count
ToggleButtonCount maintains a counter in the state machine’s data that counts the number of times it turned on.
type ToggleButtonWithCountData = {
count: number
}
export class ToggleButtonWithCount
extends StateMachine<ToggleButtonWithCountData> {
initialState = 'off'
handlers: Handlers<ToggleButtonWithCountData> = [
// if we get 'flip' in 'off', go to 'on'
// and increment data.count
['cast#flip#off', ({data}) => nextState('on')
.data({count: {$set: data.count + 1}})],
// flip from on goes back to off.
['cast#flip#on', 'off']
]
initialData: ToggleButtonWithCountData = {
count: 0
}
flip() {
this.cast('flip')
}
}
let button = new ToggleButton()
button.startSM()
// initial state
console.log(await button.getState()) // 'off'
button.flip()
console.log(await button.getState()) // 'on'
button.flip()
console.log(await button.getState()) // 'off'
Push Button Countdown Timer
PushButtonCountdownTimer turns on when pushed and starts a genericTimeout
. The button turns off when the timer fires.
type PushButtonCountdownTimerData = {
timeout: Timeout
}
class PushButtonCountdownTimer extends StateMachine<PushButtonCountdownTimerData> {
initialState = 'off'
handlers: Handlers<PushButtonCountdownTimerData> = [
// Start a generic timer and go to 'on'
['cast#push#off', ({data}) => nextState('on').timeout(data.timeout)],
// when we get 'genericTimeout' in 'on',
// go back to 'off'
['genericTimeout#*_#on', 'off']
]
constructor(timeout: Timeout) {
super()
this.initialData = {timeout}
}
push() {
this.cast('push')
}
}
A Hotel Safe
See HotelSafe
simulates a type of safe frequently seen in hotel rooms.
Locking the Safe
- Press Reset (R). The display will prompt for a new code.
- Enter a 4 digit code and then press Lock (L).
- The code will flash. The safe is now locked.
Unlocking the Safe
- Enter the 4 digit code.
- The safe will flash OPENED if the code is correct. The safe is now open.
- If the code is incorrect, the display will flash ERROR and the safe will stay locked.
/**
* The state machine's data type
*/
type SafeData = {
code: Array<number>,
input: Array<number>,
timeout: Timeout,
codeSize: number,
message?: string
}
export default class HotelSafe extends StateMachine<SafeData> {
handlers: Handlers<SafeData> = [
// Clear data when safe enters OPEN
['enter#*_#open', () => keepState().data({
code: {$set: []},
input: {$set: []},
message: {$set: 'Open'},
})],
// User pressed RESET -- get new code
['cast#reset#open', () => nextState('open/locking').data({
message: {$set: 'Enter Code'},
})],
// Track the last {codeSize} digits.
// show code on display. Repeat state for setting timeout
['cast#button/:digit#open/locking', ({args, data}) => {
let code = pushFixed(Number(args.digit), data.code, data.codeSize)
return repeatState().data({
code: {$set: code},
message: {$set: code.join('')},
})
}],
// User pressed LOCK. CLose safe if code is long enough
// else, repeat state (sets timeout on reentry)
['cast#lock#open/locking', ({data}) =>
data.code.length !== data.codeSize ?
repeatState() :
nextState('closed/success').data({
message: {$set: `**${data.code.join('')}**`},
})],
// Clear input when safe is closed
['enter#*_#closed', () => keepState().data({
input: {$set: []},
message: {$set: 'Locked'}
})],
// Postpone button press and go to closed/unlocking
['cast#button/*_#closed', ({}) =>
nextState('closed/unlocking').postpone()],
// User entered digit(s).
// Keep state if code is not long enough
// OPEN if input matches code
// go to MESSAGE if code does not match and set a timeout
['cast#button/:digit#closed/unlocking', ({args, data}) => {
let digit = Number(args.digit)
let input = data.input.concat(digit)
// code is the correct length. Decision time.
if (input.length >= data.code.length) {
let [state, msg] = arrayEqual(data.code, input) ?
['open/success', "Opened"] :
['closed/error', "ERROR"]
return nextState(state).data({message: {$set: msg}})
}
// Not long enough. Keep collecting digits.
// Show masked code. Repeat state for
// setting timeout
return repeatState().data({
input: {$push: [digit]},
message: {$set: "*".repeat(input.length)}
})
}],
// These states timeout on inactivity (eventTimeout)
[['enter#*_#open/locking',
'enter#*_#closed/unlocking'], ({data}) =>
keepState().eventTimeout(data.timeout)],
// these states just timeout
['enter#*_#:state/*_', ({data}) =>
keepState().timeout(data.timeout)],
// If we timeout in a sub state, go to the base state
[['genericTimeout#*_#:state/*_',
'eventTimeout#*_#:state/*_',], ({args}) =>
nextState(args.state)],
]
initialData: SafeData = {
code: [],
codeSize: 4,
timeout: 200,
input: [],
}
initialState = 'open'
constructor(timeout: Timeout) {
super()
this.initialData.timeout = timeout
}
/**
* Safe Interface. casts 'reset'
*/
reset() {
this.cast('reset')
}
/**
* Safe Interface. cast 'lock'
*/
lock() {
this.cast('lock')
}
/**
* Safe Interface. send button digit
* @param digit
*/
button(digit: number) {
this.cast({button: digit})
}
}
Here’s the hotel safe’s state diagram. The collection of open(/*)
states (and likewise, closed(/*)
states) represents a state machine and the rule of thumb is to implement it in a separate machine. Complex states however, make the job easier for simple hierarchies, as shown above.
Index
Enumerations
Classes
- CallEvent
- CastEvent
- EmitAction
- EnterEvent
- EventTimeoutAction
- EventTimeoutEvent
- GenericTimeoutAction
- GenericTimeoutEvent
- InternalEvent
- KeepState
- KeepStateAndData
- KeepStateBuilder
- NextEventAction
- NextState
- NextStateBuilder
- NextStateWithData
- RepeatState
- RepeatStateAndData
- RepeatStateBuilder
- ReplyAction
- Response
- ResultBuilder
- StateMachine
- StateTimeoutAction
- StateTimeoutEvent
- Stop
Interfaces
Type aliases
- ActionType
- AnimOptions
- ComplexState
- DataProxy
- EventContext
- EventExtra
- EventType
- From
- Handler
- HandlerFn
- HandlerOpts
- HandlerResult
- HandlerResult2
- HandlerRoute
- HandlerSpec
- Handlers
- NamedPrimitiveObject
- NextStateWithEventTimeout
- Primitive
- PrimitiveObject
- ResultType
- State
- Timeout
Variables
Functions
- data
- eventTimeout
- internalEvent
- isPromiseResult
- keepState
- nextEvent
- nextState
- repeatState
- reply
- stateTimeout
- timeout
Type aliases
ActionType
Ƭ ActionType: "reply" | "nextEvent" | "postpone" | "stateTimeout" | "eventTimeout" | "genericTimeout" | "emit"
Transition actions
AnimOptions
Ƭ AnimOptions: object
Type declaration
Optional
delay: undefined
| number
Delay between context switches
Optional
includeDefault: undefined
| false
| true
Include switching when a default handler is involved
ComplexState
Ƭ ComplexState: Array
<string
> | NamedPrimitiveObject
A complex state. If an object, must have a 'name' key which specifies the base state name.
DataProxy
Ƭ DataProxy: object
Type declaration
get : function
▸ get(): T
Returns: T
set : function
▸ set(value: T
, state: State): void
Parameters:
Param | Type |
---|---|
value | T |
state | State |
Returns: void
EventContext
Ƭ EventContext: Primitive | PrimitiveObject
Events can accept simple arguments of type {Primitive}, or a {PrimitiveObject}
EventExtra
Ƭ EventExtra: any
Non {Primitive|PrimitiveObject} arguments to events.
EventType
Ƭ EventType: "call" | "cast" | "enter" | "eventTimeout" | "stateTimeout" | "genericTimeout" | "internal"
State machine events
From
Ƭ From: string
The caller's address for a {CallEvent}
Handler
Ƭ Handler: HandlerFn<TData
> | State | NextStateWithEventTimeout
Route handler. Can be a function, a state or a [state, event timeout] tuple
HandlerFn
Ƭ HandlerFn: function
Handler function
Type declaration
▸(opts: HandlerOpts<TData
>): HandlerResult2<TData
>
Parameters:
Param | Type |
---|---|
opts | HandlerOpts<TData > |
Returns: HandlerResult2<TData
>
HandlerOpts
Ƭ HandlerOpts: object
Values passed to a {Handler}
Type declaration
args: object
current: State
data: TData
event: Event
route: string
HandlerResult
Ƭ HandlerResult: Result
| ResultWithData
<TData
> | ResultBuilder | void
Result of a handler invocation. {ResultBuilder.getResult()} is invoked to convert a {ResultBuilder} to a {Result} {void} implies {KeepStateWithData}
HandlerResult2
Ƭ HandlerResult2: HandlerResult<TData
> | Promise
<HandlerResult<TData
>>
HandlerRoute
Ƭ HandlerRoute: string
| Array
<string
>
The key of a handler. Can be {string|Array}. If {Array}, the routes are treated as a boolean OR. Any matching route in the array will invoke the handler.
HandlerSpec
Ƭ HandlerSpec: [HandlerRoute, Handler<TData
>] | object
A {HandlerRoute} to {Handler} entry. Can be a tuple or object with string keys. If an object, the keys are treated as {string} routes.
Handlers
Ƭ Handlers: Array
<HandlerSpec<TData
>>
List of route handlers
NamedPrimitiveObject
Ƭ NamedPrimitiveObject: object
Like {PrimitiveObject}, but expects a key 'name'
Type declaration
name: string
NextStateWithEventTimeout
Ƭ NextStateWithEventTimeout: [State, Timeout]
Handlers can be specified as a tuple of the next {State} and a {EventTimeout}
Primitive
Ƭ Primitive: number
| string
| boolean
| null
| undefined
Our definition of primitives (doesn't include symbol)
PrimitiveObject
Ƭ PrimitiveObject: object
Object with string keys and primitive values
Type declaration
ResultType
Ƭ ResultType: "none" | "keepState" | "keepStateAndData" | "nextState" | "nextStateWithData" | "repeatState" | "repeatStateAndData" | "stop" | "builder"
Hander results
State
Ƭ State: string
| ComplexState
A State in the state machine.
Timeout
Ƭ Timeout: number
| string
Timeout values. Milliseconds if {number}. For string values, see {NTimer}
Variables
<Const>
Emittery
● Emittery: any
= require('emittery')
<Const>
StablePriorityQueue
● StablePriorityQueue: any
= require("stablepriorityqueue")
Functions
data
▸ data<TData
>(spec: Spec
<TData
>): ResultBuilder
Helper to only mutate data
Type parameters:
TData
Parameters:
Param | Type | Description |
---|---|---|
spec | Spec <TData > | - |
Returns: ResultBuilder
eventTimeout
▸ eventTimeout(time: Timeout): KeepStateBuilder
Helper function to set the event timeout timer
Parameters:
Param | Type | Description |
---|---|---|
time | Timeout | - |
Returns: KeepStateBuilder
internalEvent
▸ internalEvent(context?: EventContext, extra?: EventExtra): KeepStateBuilder
Helper function to insert an 'internal' event
Parameters:
Param | Type | Description |
---|---|---|
Optional context | EventContext | - |
Optional extra | EventExtra | - |
Returns: KeepStateBuilder
isPromiseResult
▸ isPromiseResult<TData
>(r: any
): boolean
Type parameters:
TData
Parameters:
Param | Type |
---|---|
r | any |
Returns: boolean
keepState
▸ keepState(): KeepStateBuilder
Returns a KeepStateBuilder
Returns: KeepStateBuilder
nextEvent
▸ nextEvent(type: EventType, context?: EventContext, extra?: EventExtra): KeepStateBuilder
Helper function to insert a new event
Parameters:
Param | Type | Description |
---|---|---|
type | EventType | - |
Optional context | EventContext | - |
Optional extra | EventExtra | - |
Returns: KeepStateBuilder
nextState
▸ nextState(state: State): NextStateBuilder
Returns a NextStateBuilder
Parameters:
Param | Type | Description |
---|---|---|
state | State | - |
Returns: NextStateBuilder
repeatState
▸ repeatState(): RepeatStateBuilder
Returns a RepeatStateBuilder
Returns: RepeatStateBuilder
reply
▸ reply(from: string
, msg: any
): KeepStateBuilder
Helper function to return a reply
Parameters:
Param | Type | Description |
---|---|---|
from | string | - |
msg | any | - |
Returns: KeepStateBuilder
stateTimeout
▸ stateTimeout(time: Timeout): KeepStateBuilder
Helper function to set a state timeout (keeps state and data)
Parameters:
Param | Type | Description |
---|---|---|
time | Timeout | - |
Returns: KeepStateBuilder
timeout
▸ timeout(time: Timeout): KeepStateBuilder
helper function to set a generic timer
Parameters:
Param | Type | Description |
---|---|---|
time | Timeout | - |
Returns: KeepStateBuilder