Hyperapp state machine web app

How to write a web application using a state machine and Hyperapp framework.

Writing apps is hard. It is hard for many reasons, but for me one of the main reasons is the difficulty of thinking about all the possible states the application can be in. The web app is not a collection of routes and elements, but a model that changes from one state to another - and the UI merely reflects the current state.

So how can we express this in the front end world using JavaScript libraries and frameworks? This post will show how to use two small libraries to create a really simple application

  • we will model application state and transitions using xstate state machine library
  • we will draw the user interface using hyperapp micro framework

Let us start, you can find the code at bahmutov/lights-example.

State machine

I have copied the state machine code straight from the xstate example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Machine } from 'xstate';

export default Machine({
id: 'light',
initial: 'green',
states: {
green: {
on: {
TIMER: 'yellow',
}
},
yellow: {
on: {
TIMER: 'red',
}
},
red: {
on: {
TIMER: 'green',
}
}
}
})

The machine starts in the initial green state, and on TIMER event can go from green to yellow state. If we send the TIMER event again, it will go from yellow to red. Send TIMER again and it is back to green state, completing the cycle. You can visualize this state machine at https://statecharts.github.io/xstate-viz/

Light machine state chart

Application

Now that we have our state machine, let us write an application using Hyperapp JavaScript framework. The first thing we need is to decide what our state object would be. This is different from the state machine above. Just to keep things clear, our application's state object will have just a single property named xstate with our state machine. At the start, it will be the initial state of the state machine.

app.js
1
2
3
4
5
6
import { app } from 'hyperapp'
import { actions } from './actions'
import machine from './machine'
import { view } from './view'

app({ xstate: machine.initialState }, actions, view, document.body)

The view function renders a single element and sets its class based on the current state's value

view.js
1
2
3
4
5
import { h } from 'hyperapp'

export const view = ({ xstate }, actions) => {
return <div id='app' class={xstate.value} onclick={actions.onclick} />
}

When the user clicks on the element, action onclick is triggered, and it should "move" our application's machine from one state to the next.

action.js
1
2
3
4
5
6
7
8
9
10
11
12
13
import machine from './machine'

const onclick = () => ({ xstate }) => {
const currentState = xstate.value
const newState = machine.transition(currentState, 'TIMER')
console.log(
'onclick, state changes from %s to %s',
currentState,
newState.value
)
return { xstate: newState }
}
export const actions = { onclick }

Beautiful, the xstate object is passed by the Hyperapp framework and we change it according to the ./machine rules and return it from the onclick action. Hyperapp then triggers the view function again passing the updated { xstate } object and the process repeats.

Page

First, our HTML page only includes a style and a script tag

index.html
1
2
3
4
5
6
<head>
<link rel="stylesheet" href="app.css" />
</head>
<body>
<script src="app.js"></script>
</body>

I am going to serve it using Parcel bundler and here is the magical web application in action. I am clicking on the page several times.

Light machine application

Testing from state machine

The above web application renders user interface as a function of the current state. Literally, the function view takes { xstate } as a parameter and returns virtual DOM node that should be shown to the user

1
vDOM = view({ xstate })

How should we test this application? Well we could define our tests "normally" by looking at the application's DOM and driving the app. Using Cypress.io I can write this test like

cypress/integration/manual.js
1
2
3
4
5
6
7
8
9
10
11
12
13
/// <reference types="cypress" />
it('changes color', () => {
cy.visit('http://localhost:1234')
cy
.get('#app')
.should('have.class', 'green') // initial
.click()
.should('have.class', 'yellow')
.click()
.should('have.class', 'red')
.click()
.should('have.class', 'green') // back to initial
})

and it works, the application really rotates through the CSS classes

Manual end-to-end test

But how do we know that these transitions we wrote by looking at the application's HTML are actually correct? What if our web application renders the state incorrectly or moves the state to a wrong state when the user clicks? With a state machine we can do a neat trick: we can generate the end-to-end test from the state machine!

I will write a small utility script that loads the machine and then traverses all paths - and keeps only the ones from the initial state "green".

scripts/paths.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { getShortestValuePaths } from 'xstate/lib/graph'
import machine from '../src/machine'

const paths = getShortestValuePaths(machine, {
events: {
TIMER: [{ type: 'TIMER' }]
}
})

function deserializeEventString (eventString) {
const [type, payload] = eventString.split(' | ')

return {
type,
...(payload && payload !== 'undefined' ? JSON.parse(payload) : {})
}
}

const pathsFromGreen = Object.keys(paths).filter(stateString => {
// console.log('state string', stateString)
const result = deserializeEventString(stateString)
// console.log('result', result)
return result.type === '"green"'
})

console.log(paths[pathsFromGreen[0]])

The result printed is

1
2
3
[ { state: 'yellow', event: 'TIMER | {}' },
{ state: 'red', event: 'TIMER | {}' },
{ state: 'green', event: 'TIMER | {}' } ]

Normally I would save this list into a JSON file, but for such a short list I can manually copy it to a new spec called cypress/integration/auto.js. But first, I will change my view.js to export a few utilities to share code between the application and the test, like selector and getAppClass

src/view.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { h } from 'hyperapp'

// we expose the selector to let the tests know
// how to find this element
export const selector = '#app'

// in our simple application we set the CSS class
// to be the same as the name of the state
export const getAppClass = value => value

export const view = ({ xstate }, actions) => {
return (
<div id='app' class={getAppClass(xstate.value)} onclick={actions.onclick} />
)
}

Here is my automated spec file. Notice how it uses imported helper methods from view.js to drive particular DOM implementation without repeating the code

cypress/integration/auto.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <reference types="cypress" />
import machine from '../../src/machine'
import { getAppClass, selector } from '../../src/view'

// test generated from the state machine path traversal
it('changes color from state', () => {
cy.visit('http://localhost:1234')

// we start in the initial state
cy.get(selector).should('have.class', getAppClass(machine.initialState.value))

const path = [
{ state: 'yellow', event: 'TIMER | {}' },
{ state: 'red', event: 'TIMER | {}' },
{ state: 'green', event: 'TIMER | {}' }
]
path.forEach(({ state, event }) => {
// event name to Cypress method could be dynamic
// in our case it is always "TIMER" -> "cy.click()"
cy.log(`checking transition to ${state}`)
cy.get(selector).click().should('have.class', getAppClass(state))
})
})

Our test behaves the same way, but now it is derived from the state machine and its possible transitions. Plus it uses a little bit of the logic from the view code, thus our UI and our tests are derived like this

1
2
vDOM = view({ xstate })
e2eTests = f(machine, view)

and our tests now cover 100% of the possible states and transitions, no matter what code coverage metric says (for our bundle the e2e test auto.js covers about 66% of the total bundle code, which includes application code and hyperapp and xstate libraries).

Related