0.1.11 • Published 1 year ago

@picoware/state v0.1.11

Weekly downloads
-
License
Apache-2.0
Repository
gitlab
Last release
1 year ago

@picoware/state

A tiny full-featured library for composable finite state machines and statecharts :

  • 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

@picoware/state aims at providing a seamless developper experience. The API is carefully crafted to be legible and allows easy splitting and composition of statemachines. Types are provided for intellisense.

The library runs both in browser and on Node.js

Installation

npm install @picoware/state

Usage

Finite state machine

Basic example

Here is a simple toggle machine. Comments show what is logged in the console.

import { parseMachine } from '@picoware/state';

// Machine structure definition
const machine = parseMachine("root", {
    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.state))

// 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 { parseMachine } from '@picoware/state';

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 = parseMachine("root", {
    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.state))

// 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:

const guards = {
    isRaining: () => true, // return something that depends on the context
    isHot: () => false
}

const machine = parseMachine("root", {
    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.state))

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 { parseMachine } from '@picoware/state';

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 = parseMachine("root", {
    initial: 'OFF',
    states: {
        ON: {
            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.state))

// 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

There are two reserved names for final states : "ERROR" and "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:

const machine = parseMachine("root", {
    initial: 'COMPUTE',
    states: {
        COMPUTE: {
            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: {},
                DONE: {},
            }
        },
        RESULT: {}
    }
});

machine.status.onChanged((status) => console.log(status.state))

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: {
  final: "error"
}
MY_DONE: {
  final: "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:

const machine = parseMachine("root", {
    initial: 'OFF',
    states: {
        ON: {
            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.state))

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 :

const machine = parseMachine("root", {
    parallel: true,
    states: {
        BOLD: {
            initial: 'OFF',
            states: {
                ON: {
                    events: {
                        TOGGLE_BOLD: { target: "OFF" }
                    }
                },
                OFF: {
                    events: {
                        TOGGLE_BOLD: { target: "ON" }
                    }
                }
            }
        },
        UNDERLINE: {
            initial: 'OFF',
            states: {
                ON: {
                    events: {
                        TOGGLE_UNDERLINE: { target: "OFF" }
                    }
                },
                OFF: {
                    events: {
                        TOGGLE_UNDERLINE: { target: "ON" }
                    }
                }
            }
        },
        ITALICS: {
            initial: 'OFF',
            states: {
                ON: {
                    events: {
                        TOGGLE_ITALICS: { target: "OFF" }
                    }
                },
                OFF: {
                    events: {
                        TOGGLE_ITALICS: { target: "ON" }
                    }
                }
            }
        },
    }
});

machine.status.onChanged((status) => console.log(status.state))

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 { parseMachine, raise } from '@picoware/state';

const actions = {
    doStuffA: () => console.log("Do stuff A"),
    doStuffB: () => console.log("Do stuff B"),
}

const machine = parseMachine("root", {
    parallel: true,
    states: {

        STATE_A: {
            events: {
                DO_STUFF_A: {
                    target: "self",
                    actions: [
                        actions.doStuffA,
                        raise("DO_STUFF_B"), // here we raise a new event on the parent
                    ]
                }
            }
        },

        STATE_B: {
            events: {
                DO_STUFF_B: { target: "self", actions: actions.doStuffB },
            }
        },
    }
});

machine.status.onChanged((status) => console.log(status.state))

machine.start();
// ['root.STATE_A', 'root.STATE_B']

machine.send('DO_STUFF_A');
// 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:

const activities = {
    calculate: () => Promise.resolve()// return  instead a promise whose resolution depends on the context
}

const machine = parseMachine("root", {
    initial: 'COMPUTING',
    states: {
        COMPUTING: {
            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.state))

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 = parseMachine("A", {...})
const stateB = parseMachine("B", {...})

// combine them under the root node
const machine = parseMachine("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

0.1.10

1 year ago

0.1.11

1 year ago

0.1.9

1 year ago

0.1.8

1 year ago

0.1.7

1 year ago

0.1.6

2 years ago

0.1.5

2 years ago

0.1.4

2 years ago

0.1.3

2 years ago

0.1.2

2 years ago

0.1.1

2 years ago

0.1.0

2 years ago

0.0.7

2 years ago

0.0.6

2 years ago

0.0.5

2 years ago

0.0.4

2 years ago

0.0.3

2 years ago

0.0.2

2 years ago

0.0.1

2 years ago