Home

Awesome

State Machine

A minimally practical state machine in JavaScript.

Installation

npm install --save @deadb17/state-machine

Example

Example graph

Define a graph

Start by defining a graph with a plain JavaScript object.

/** @type {Machine.Graph} */
const g = {
  a: {
    ENTER: callback,
    go: { to: ['b'], call: callback },
    loop: { to: ['a'], call: callback },
    end: { to: ['c'], call: callback },
    LEAVE: callback,
  },
  b: {
    ENTER: callback,
    go: { to: ['a'], call: callback },
    LEAVE: callback,
  },
  c: null,
};

Its keys (a, b and c) represent the current state.

Their value is another object where the keys are the events that the current state responds to.

The value is another object with two keys: to and call:

There are two special events: ENTER and LEAVE which get called automatically.

Finally, states with null values or that only respond to ENTER events are considered terminal states.

Define the callback

Next define the callback for each state transition. Here, the same one is reused for simplicity.

/** @type {Machine.Call<Store>} */
function callback(machine, toStates, { type }) {
  machine.count++;
  machine.stack.push(`${type}: ${machine.state} -> ${toStates[0]}`);
  return toStates[0];
}

The callback takes three parameters:

  1. The machine itself: All machines have the same interface. Each machine can have specific additional properties.
  2. An array of possible next states. The callback must return one of them.
  3. The event that triggered the call.

Machine interface

type Machine = Readonly<{
  graph: Graph;
  state: State;
  handleEvent: (event: MiniEvent) => void;
}>;

type MiniEvent = { readonly type: string } | Event;

Create a machine

import { createMachine } from './index.js';

const m0 = createMachine(g, 'a');

createMachine takes the graph that was defined previously and the initial state as a string.

Optionally, extend the machine with custom properties

/**
 * @typedef {object} Store
 * @prop {number} count
 * @prop {string[]} stack
 */
/** @type {Store} */
const s0 = { count: 0, stack: [] };

/** @type {Machine.Machine & Store} */
const m = Object.assign(m0, s0);

this results in a machine with the following properties:

assert.equal(m.state, 'a');
assert.equal(m.graph, g);
assert.equal(m.count, 0);
assert.deepEqual(m.stack, []);

Notice that a.ENTER didn't get called in this case as the machine is not transitioning from another state when it is started. It will get called later when it is a transition.

Send events

Sending the go event:

m.handleEvent({ type: 'go' });

Results in:

assert.equal(m.state, 'b');
assert.equal(m.count, 3);
assert.deepEqual(m.stack, ['go: a -> b', 'LEAVE: a -> b', 'ENTER: a -> b']);

Sending the go event now:

m.handleEvent({ type: 'go' });
assert.equal(m.state, 'a');
assert.equal(m.count, 6);
assert.deepEqual(m.stack, [
  'go: a -> b',
  'LEAVE: a -> b',
  'ENTER: a -> b',
  'go: b -> a',
  'LEAVE: b -> a',
  'ENTER: b -> a',
]);

Sending the loop event:

m.handleEvent({ type: 'loop' });
assert.equal(m.state, 'a');
assert.equal(m.count, 7);
assert.deepEqual(m.stack, [
  'go: a -> b',
  'LEAVE: a -> b',
  'ENTER: a -> b',
  'go: b -> a',
  'LEAVE: b -> a',
  'ENTER: b -> a',
  'loop: a -> a',
]);

Sending the end event:

m.handleEvent({ type: 'end' });
assert.equal(m.state, 'c');
assert.equal(m.count, 9);
assert.deepEqual(m.stack, [
  'go: a -> b',
  'LEAVE: a -> b',
  'ENTER: a -> b',
  'go: b -> a',
  'LEAVE: b -> a',
  'ENTER: b -> a',
  'loop: a -> a',
  'end: a -> c',
  'LEAVE: a -> c',
]);

In terminal state nothing else can happen:

m.handleEvent({ type: 'go' });
m.handleEvent({ type: 'LEAVE' });
m.handleEvent({ type: 'ENTER' });
assert.equal(m.state, 'c');
assert.equal(m.count, 9);

state-machine Copyright 2020 © DEADB17 DEADB17@gmail.com.
Distributed under the GNU LGPLv3.