Minimal, tiny, zero-dependency state-machine implementation in javascript.
npm install @desicochrane/machine
- Define a new state machine with a starting state and error function:
const machine = StateMachine('off', (m, err) => { console.log(err) })
- Define a transition via
machine.transition(state, event, callback)
:machine.transition('off', 'switchOn', (m, data) => { m.setState('on') m.model.counter += data }) machine.transition('on', 'switchOff', (m) => { m.setState('off') }) machine.transition('on', 'switchOn')
- Instantiate your machine:
const m = machine.start({ counter: 0 })
- Dispatch events
m.dispatch('switchOn', 1) m.dispatch('switchOn', 10) m.dispatch('switchOff') m.dispatch('switchOn', 2) console.log(m.model.counter) // 3 m.dispatch('switchOff') m.dispatch('switchOff') // error: transition off:switchOff not defined
Consider implementing a wrapper around a browser websocket connection. Your API might support starting and stopping the websocket connection, handling reconnects when there is an unexpected disconnect, and handling sending and recieving messages when the connection is open.
We can visualise how it should work with the following state machine:
The machine is initially in the Stopped
state. We specify this when we create our state machine:
// WebsocketMachine.js
import StateMachine from './StateMachine'
const WebsocketMachine = StateMachine('Stopped')
This demonstrates how the StateMachine function is used to bootstrap a new state machine. The function takes as first argument an enum to set the initial state, and an optional second argument which is a callback for when there is an error.
From the Stopped
state there is only the Start
transition event, which we can specify:
// WebsocketMachine.js
import StateMachine from './StateMachine'
const WebsocketMachine = StateMachine('Stopped')
WebsocketMachine.transition('Stopped', 'Start', (m, data) => {
m.setState('Connecting')
// todo: bootstrap new ws connection
})
This example demonstrates the transition
method, which takes three arguments:
- A State enum
- An Event enum
- A callback function which is called when that event is fired from that state.
The callback function itself takes two arguments:
- The state machine instance itself
- (Optional) any data passed along with the event.
The state machine instance supports two methods:
m.setState(state)
which is used to change the state of the state machinem.dispatch(event, data)
which is used to dispatch an event with optionally any data
To illustrate how dispatching events works, we can next implement the bootstrapping of the websocket connection:
// WebsocketMachine.js
import StateMachine from './StateMachine'
const WebsocketMachine = StateMachine('Stopped')
WebsocketMachine.transition('Stopped', 'Start', (m, data) => {
m.setState('Connecting')
bootstrapWSConnection(m)
})
function bootstrapWSConnection(m) {
// create a new websocket connection
const conn = new WebSocket('wss://app.com')
// proxy each websocket event to our own dispatcher
conn.onopen = () => m.dispatch('ConnOpen')
conn.onclose = () => m.dispatch('ConnClosed')
conn.onmessage = msg => m.dispatch('Msg', msg)
ws.model.conn = conn
}
Here you can see we added a helper function bootstrapWSConnection
which creates a new websocket connection and proxies all of its events to the machine's own dispatcher. Notice that we only pass data along with the Msg
event. Finally we save the connection onto the model
property of the machine, which is a special property for the state machine instance to save its own model data.
We can implement the rest of our state machine in the same way to arrive at the final result:
import StateMachine from './StateMachine'
const WebsocketMachine = StateMachine('Stopped')
// Stopped
WebsocketMachine.transition('Stopped', 'Start', m => {
m.setState('Connecting')
bootstrapWSConnection(m)
})
// Connecting
WebsocketMachine.transition('Connecting', 'ConnOpen', m => {
m.setState('Connected')
})
WebsocketMachine.transition('Connecting', 'ConnClosed', m => {
m.setState('Disconnected')
setTimeout(() => m.dispatch('Retry'), 1000)
})
// Connected
WebsocketMachine.transition('Connected', 'Stop', (m, data) => {
m.setState('Stopping')
m.model.conn.close()
m.model.conn = null
})
WebsocketMachine.transition('Connected', 'ConnClosed', m => {
m.setState('Disconnected')
setTimeout(() => m.dispatch('Retry'), 1000)
})
WebsocketMachine.transition('Connected', 'Message', (m, data) => {
m.model.onMessage(data)
})
// Disconnected
WebsocketMachine.transition('Disconnected', 'Retry', m => {
m.setState('Connecting')
bootstrapWSConnection(m)
})
WebsocketMachine.transition('Disconnected', 'Stop', m => {
m.setState('Stopped')
})
// Stopping
WebsocketMachine.transition('Stopping', 'ConnClosed', m => {
m.setState('Stopped')
})
export default WebsocketMachine
Now that we are done scaffolding our state machine, we can use it:
// app.js
import WebsocketMachine from './WebsocketMachine'
const model = {
onMessage(msg) {
console.log(msg)
}
}
const ws = WebsocketMachine.start(model)
This illustrates the start
method, which returns a new instance of the state machine. It takes as argument a model which can be mutated and accessed internally by the state machine - in this case it provides a callback to be used when there is a websocket message arriving.
Consider next implementing a login form. This login form should redirect immediately to the dashboard if the user is already logged in, otherwise it should show the email and password form. Once on the form, the user can change the email and passowrd before submitting the form, if they are authorized then they should be redirected to the dashboard.
Based on these requirements we can construct the following state machine:
The state machine is delightfully easy to unit test and usually goes in 3 steps:
- Setup the test with an initial state and initial model data
- Dispatch an event
- Assert final state and model data.
One convention is to structure your tests with describe(state)
and it(event)
. A full unit test suite then might be as follows:
// LoginMachineTest.js
import { expect } from 'chai'
import sinon from 'sinon'
import Machine from './LoginMachine'
import Api from './Api'
const sandbox = sinon.createSandbox()
describe('LoginMachine', () => {
afterEach(sandbox.restore)
it('starts in INIT state', () => {
// start a new instance of the machine
const m = Machine.start()
// expect the initial state is 'init'
expect(m.state).equals('Init')
})
describe('Init', () => {
it('Load', () => {
// Given we are in the Init state
const m = Machine.start()
// and that our backend api is mocked
const api = sandbox.stub(Api, 'post')
api.returns(Promise.defer().promise)
// When the 'Load' event is dispatched
m.dispatch('Load')
// Then we should be in the 'GetSession' state
expect(m.state).equals('GetSession')
// And an api request to '/get-session' should have been called
sinon.assert.calledWith(api, '/get-session')
})
})
describe('GetSession', () => {
it('OK', () => {
const m = Machine.start()
m.setState('GetSession')
m.dispatch('OK')
expect(m.state).equals('Done')
})
it('NotAuthorized', () => {
const m = Machine.start()
m.setState('GetSession')
m.dispatch('NotAuthorized')
expect(m.state).equals('Form')
})
})
describe('Form', () => {
it('ChangeEmail,ChangePassword', () => {
// Given our initial model
const model = {
email: '',
password: '',
}
const m = Machine.start(model)
// And that we are in the 'Form' state
m.setState('Form')
// When the ChangeEmail event is dispatched with an email
m.dispatch('ChangeEmail', 'hello@des.io')
// Then we should remain in the Form state
expect(m.state).equals('Form')
// And our model should have the updated email
expect(model.email).equals('hello@des.io')
// When the ChangePassword event is dispatched with a password
m.dispatch('ChangePassword', 'abc123')
// Then we should remain in the Form state
expect(m.state).equals('Form')
// And our model should have the updated email
expect(model.password).equals('abc123')
})
it('Submit', () => {
// Given our initial model
const model = {
email: 'hell@des.io',
password: 'abc123',
}
const m = Machine.start(model)
// and that our backend api is mocked
const api = sandbox.stub(Api, 'post')
api.returns(Promise.defer().promise)
// And that we are in the 'Form' state
m.setState('Form')
// When the Submit event is fired
m.dispatch('Submit')
// Then we should be in the Authenticate state
expect(m.state).equals('Authenticate')
// And an api request to '/authenticate' should have been called with the form data
sinon.assert.calledWith(api, '/authenticate', {
email: 'hell@des.io',
password: 'abc123',
})
})
})
describe('Authenticate', () => {
it('OK', () => {
const m = Machine.start()
m.setState('Authenticate')
m.dispatch('OK')
expect(m.state).equals('Done')
})
it('NotAuthorized', () => {
const m = Machine.start()
m.setState('Authenticate')
m.dispatch('NotAuthorized')
expect(m.state).equals('Form')
})
})
})
We can scaffold the login state machine as follows:
// LoginMachine.js
import StateMachine from '../src/StateMachine'
import Api from './Api'
export const States = {
Init: 'Init',
GetSession: 'GetSession',
Form: 'Form',
Authenticate: 'Authenticate',
Done: 'Done',
}
export const Events = {
Load: 'Load',
OK: 'OK',
ChangeEmail: 'ChangeEmail',
ChangePassword: 'ChangePassword',
Submit: 'Submit',
}
export const Errors = {
Unexpected: 'Unexpected',
NotAuthorized: 'NotAuthorized',
}
export function NewModel() {
return {
email: '',
password: '',
}
}
const Machine = StateMachine(States.Init, (m, err) => {
m.setState(Errors.Unexpected)
throw Error(err)
})
Machine.transition(States.Init, Events.Load, m => {
m.setState(States.GetSession)
Api.post('/get-session')
.then(() => m.dispatch(Events.OK))
.catch(err => m.dispatch(err))
})
Machine.transition(States.GetSession, Events.OK, m => {
m.setState(States.Done)
})
Machine.transition(States.GetSession, Errors.NotAuthorized, m => {
m.setState(States.Form)
})
Machine.transition(States.Form, Events.ChangeEmail, (m, email) => {
m.model.email = email
})
Machine.transition(States.Form, Events.ChangePassword, (m, password) => {
m.model.password = password
})
Machine.transition(States.Form, Events.Submit, m => {
m.setState(States.Authenticate)
Api.post('/authenticate', m.model)
.then(() => m.dispatch(Events.OK))
.catch(err => m.dispatch(err))
})
Machine.transition(States.Authenticate, Events.OK, m => {
m.setState(States.Done)
})
Machine.transition(States.Authenticate, Errors.NotAuthorized, m => {
m.setState(States.Form)
})
export default Machine
import LoginMachine, { States, Events, Errors, NewModel } from './LoginMachine'
const m = LoginMachine.start(NewModel(), { logging: true })
new Vue({
data: { m },
mounted: () => m.dispatch(Events.Load),
computed: {
err: () => m.inState(Errors.Unexpected),
showInit: () => m.inState(States.Init, States.GetSession),
showForm: () => m.inState(States.Form, States.Authenticate),
showDashboard: () => m.inState(States.Done),
isSubmitting: () => m.inState(States.Authenticate),
},
methods: {
changeEmail: email => m.dispatch(Events.ChangeEmail, email),
changePassword: password => m.dispatch(Events.ChangePassword, password),
submit: () => m.dispatch(Events.Submit),
},
template: `
<div>
<div v-if="err">Whoops! Something went wrong.</div>
<div v-if="showInit">Loading...</div>
<div v-if="showDashboard">Welcome to Dashboard</div>
<div v-if="showForm">
<div>Log in</div>
<form @submit.prevent="submit">
<input @input="evt => changeEmail(evt.target.value)"
:value="m.model.email"
:disabled="isSubmitting"
placeholder="Email">
<input type="password"
@input="evt => changePassword(evt.target.value)"
:value="m.model.password"
:disabled="isSubmitting"
placeholder="password">
<button type="submit" :disabled="isSubmitting">
<span v-if="isSubmitting">Logging in...</span>
<span v-else>Log in</span>
</button>
</form>
</div>
</div>
`
})