@urbn/state-machine
Version:
A small Javascript Promise-based Finite State Machine implementation
268 lines (202 loc) • 9.4 kB
Markdown
# state-machine
<!-- MarkdownTOC autolink="true" levels="1,2" -->
- [Description](#description)
- [Usage](#usage)
- [Why use a State Machine?](#why-use-a-state-machine)
- [Prior Art](#prior-art)
- [Other Libraries](#other-libraries)
- [Diagrams](#diagrams)
- [API Documentation](#api-documentation)
- [Packaged Module Format](#packaged-module-format)
<!-- /MarkdownTOC -->
## Description
`state-machine` is a Javascript Finite State Machine implementation with the following design goals:
* Minimal in size
* Enforces a static and explicit configuration. This means two things:
* All possible states/transitions must be defined upon machine creation
* A given transition from a state goes to a single deterministic destination state
## Usage
Install and save the library:
```
npm install --save @urbn/state-machine
```
Use the library in your application:
```
const stopLight = new StateMachine({
states: {
green: {
transitions: {
change: 'yellow',
},
},
yellow: {
transitions: {
change: 'red',
},
},
red: {
transitions: {
change: 'green',
},
},
},
onEnter(data, newState, oldState) {
console.log(`Transitioned from ${oldState} -> ${newState}`);
},
}, 'red');
setInterval(() => stopLight.transition('change'), 3000);
```
## Why use a State Machine?
It can be advantageous in user-interface development to think of your interface as a Finite State Machine. When implemented correctly, this can have a number of benefits over simply representing application "state" as a combination of local application data or fields in a shared Redux/Vuex store.
A few of these benefits are:
* Ability to visualize the various states and transitions of the UI
* Common language in which to communicate between developers, QA engineers, interface designers, business analysts, etc.
* Enforced coverage and consideration of error states and edge cases
## Prior Art
State Machines are nothing new in Computer Science - they are known to be incredibly robust, and are often chosen to control [mission critical systems](https://ti.arc.nasa.gov/publications/10841/download/). However, what is relatively new as of ~2018 is the concept of representing front end application state as a state machine. There are plenty of great articles/videos on the subject, so we'll just link to them and won't bother to re-hash them all here.
* Our specific use-case is a Finite State Machine, also known as a [Mealy Machine](https://en.wikipedia.org/wiki/Mealy_machine) - initially conceived in 1955 😮
* [Video: Infinitely Better UIs with Finite Automata](https://www.youtube.com/watch?v=VU1NKX6Qkxc)
* [The Rise of State Machines](https://www.smashingmagazine.com/2018/01/rise-state-machines/)
* [Upgrade your React UI with state machines](https://hackernoon.com/upgrade-your-react-ui-with-state-machines-30d1298e90be)
* [Restate Your UI: Using State Machines to Simplify User Interface Development](http://blog.cognitect.com/blog/2017/5/22/restate-your-ui-using-state-machines-to-simplify-user-interface-development)
## Other Libraries
`state-machine` was born after a quick evaluation of a few other open-source libraries. All of these other libraries have merit, they just weren't quite the right fit for our needs.
* [xstate](https://github.com/davidkpiano/xstate) - Seemingly the most robust library which goes well beyond simple FSM, but quite large in size (9Kb gzipped)
* [javascript-state-machine](https://github.com/jakesgordon/javascript-state-machine) - Uses a transition-first machine definition, as compared to our preferred state-first definition
* [Stately.js](https://github.com/fschaefer/Stately.js/) - Very similar in implementation to this library, but with 2 small issues:
* The usage of action functions to determine destination state went against our design goal of explicit/deterministic state transitions
* The current state is private and thus hard to wire up to Vue's reactivity
## Diagrams
`state-machine` is capable of automatically generating diagrams of a given State Machine that can be opened with the [GraphViz](https://www.graphviz.org/) tool. The tool can be installed with `brew install graphviz --with-app`, and then used via the command line `dot` tool, or opened from `/usr/local/Cellar/graphviz/[version]/GraphViz.app`.
To generate your `state-machine` diagrams, simply call the `.getDotFile()` method on an instantiated `StateMachine` instance and print it out to the console, and save it in a `.dot` file. Notre that this only works when `NODE_ENV !== 'production'`.
## API Documentation
### Constructor
The constructor requires both the machine configuration and the initial state for the machine, and will throw an error if either are invalid.
```
const machine = new StateMachine(configuration, initialState, initialStateErrorHandler?);
```
The full structure of the configuration object is as follows:
```
{
// The `states` field is an object with keys for every possible state
states: {
STATE1: {
// The `transitions` object lists all possible transitions out of
// STATE1 and their destination states
transitions: {
GO_TO_STATE2: 'STATE2',
},
// Callback function fired anytime STATE1 is entered
onEnter(data, newState, oldState, transition) { ... }
},
// If a state is a terminal state, it doesn't need to provide any transitions
STATE2: null,
...
},
// Callback function anytime the state changes
onEnter(data, newState, oldState, transition) { ... }
}
```
#### Asyncronous `onEnter` functions
`onEnter` functions can return promises if they need to handle asynchronous actions, and these promises will be proxied back as the return value for the `.transition()` call that entered the state. See the `.transition` documentation below for examples.
#### `initialStateErrorHandler`
Because of the async nature of state `onEnter` functions, `initialStateErrorHandler` can be passed to handle promise rejections from the `initialState`'s `onEnter` function that will be executed on the initial transition.
### currentState
Returns the current machine state name
```
const machine = new StateMachine({
states: {
purgatory: null,
},
}, 'purgatory');
console.log(machine.currentState);
// -> 'purgatory'
```
### getDotFile()
In development mode only (`NODE_ENV !== 'production')`, returns a string of the machine diagram as a DOT file. See the [Diagrams](#diagrams) section for more information.
```
const machine = new StateMachine({
states: {
off: {
transitions: {
start: 'on',
},
},
on: {
transitions: {
stop: 'off',
},
},
},
}, 'off');
console.log(machine.getDotFile());
// -> 'digraph "fsm" {
// "off";
// "on";
// "off" -> "on" [label="start"];
// "on" -> "off" [label="stop"];
// }'
```
### transition(transitionName, payload)
Transitions the machine from a current state to a new state, returning a resolved promise when the transition is complete, or a rejected promise if the transition is invalid or of the destination state onEnter function rejects.
#### Synchronous Transitions
```
const machine = new StateMachine({
states: {
off: {
transitions: {
start: 'on',
},
},
on: {
transitions: {
stop: 'off',
},
onEnter() {
console.log('Entered the on state');
},
},
},
}, 'off');
console.log(machine.currentState);
// -> 'off'
machine.transition('start');
// -> 'Entered the on state'
console.log(machine.currentState);
// -> 'on'
```
#### Asynchronous Transitions
Asyncronous `onEnter` functions can be used along with promise-based transitions as follows:
```
const machine = new StateMachine({
states: {
empty: {
transitions: {
fetchData: 'fetching',
},
},
fetching: {
transitions: {
doneFetching: 'showData',
},
async onEnter(userId) {
const data = await fetchSomeData(userId);
machine.transition('doneFetching', data);
return data;
}
},
showData: null,
},
}, 'empty');
console.log(machine.currentState);
// -> 'empty'
const user1 = await machine.transition('fetchData', 'user-1')
// user1 will contain the fetched data from the onEnter return value
console.log(machine.currentState);
// -> 'showData'
```
## Packaged Module Format
This library currently makes a few assumptions about the consuming client applications, however these may be removed in future versions.
* You are transpiling to ES5 code as necessary for your Node/Browser versions - this library uses ES6 features such as `const`, `let`, `Object.entries`, etc.
* You can support CommonJS modules - this library is not distributed in ESM or UMD module formats
* You are minifying your bundled code - this library is not distributed in a minified form