tesm
v1.5.6
Published
The Elm State Machine
Downloads
13
Readme
The Elm State Machine
The Elm State Machine is a state management library based on The Elm Architecture.
This library makes use of concepts familiar to Elm programmers (such as Model, Msg, Cmd) to manage application state in a totally pure way.
TESM is written in TypeScript. While vanilla JavaScript is supported, it is highly recommended to use TypeScript for the benefit of strict type checking.
Getting started
State example
In this example we will develop a small state that handles a subset of the application logic.
- Import the required components:
import { state, cmd, msg, ExtractValues, keys, invalid_state } from "tesm"
This example state will handle logic of some loading process.
We will have 3 states: initial
, loading
and loaded
.
Every state has a context where all the data is stored. Context is basically a plain JS object that stores fields with data.
- Let's create 2 context types for when the loading process has just started and when the loading process has completed.
type LoadingContext = {
loadingStarted: number
}
type AppContext = LoadingContext & {
loadingFinished: number
}
loadingStarted
is the time when loading has started. loadingFinished
is the time when loading has finished.
Now we can create states with attached contexts.
- Let's create our states by using an imported
state()
function.
const states = state({
initial: () => ({}),
loading: <T extends LoadingContext>(m: T) => m,
loaded: <T extends AppContext>(m: T) => m,
})
We pass an object to state()
where the field names are state names and values are functions that serve as a strictly typed boilerplate.
Next, we need a way to change the underlying state. Let's define incoming and outgoing messages that are related to our state.
Incoming messages are called Msg
in Elm world.
The only requirement for Msg
in TESM is to have a type
field. Msg
can have extra fields with data.
You can think of Msg
as events that happen in outside world and are passed to TESM state with related data.
- Let's create a couple of
Msg
.
export const msgs = msg({
started_loading: (now: number) => ({ now }),
finished_loading: (now: number) => ({ now }),
})
msgs
should be exported from the state module because they are generated by outside code.
msg()
function works similar to state()
: we pass to it an object with Msg
names and generator functions.
The convention for Msg
names is snake_case
and past tense verbs. These are the events that already happened and our state is being notified of them.APPLICATION_LOADED
, http_error_encountered
and user.loading.failed
are all good names for Msg
.
Outgoing messages are called Cmd
.
- Let's create a couple of
Cmd
.
const cmds = cmd({
startLoadingAnimation: () => ({ }),
displayPopup: (text: string) => ({ text }),
})
cmds
don't have to be exported, we will create them internally in the state module.
Cmd
are almost identical to Msg
, the only difference being the naming convention: Cmd
names are camelCase
and use present tense verbs. You should name Cmd
the same way you name methods in your code.loadUserInfo(uid: string)
, todo.create(name: string)
and cancelLoading
are all good names for Cmd
.
- Let's extract types from our objects for future use.
export type Msg = ExtractValues<typeof msgs>
export type Cmd = ExtractValues<typeof cmds>
export type Model = ExtractValues<typeof states>
- We can also export initial state. We will need it later when we get to interaction between TESM states and outside world.
export const initialState = states.initial
It's time for us to create the core of our logic: the update()
function.
This function will take care of all incoming messages and will update the state accordingly.
The signature of the update()
function is as follows:
export function update(msg: Msg, model: Model): [Model, ...Cmd[]]
If you're familiar with Elm, this function looks almost the same as Elm update function:
update : Msg -> Model -> (Model, Cmd Msg)
The differences are that TESM update()
can return multiple Cmd
if needed, and Cmd
are not tied to Msg
.
For convenience we will use constants instead of state and Msg
names, but you can use strings if you prefer to.
const STATE = keys(states)
const MSG = keys(msgs)
export function update(msg: Msg, model: Model): [Model, ...Cmd[]]
{
return invalid_state("MainState", msg, model)
}
- Let's add some logic to the
update()
function.
export function update(msg: Msg, model: Model): [Model, ...Cmd[]]
{
switch (model.state)
{
// case "initial":
case STATE.initial: switch (msg.type)
{
// case "started_loading":
case MSG.started_loading:
return [
states.loading({ loadingStarted: msg.now}),
cmds.startLoadingAnimation()
]
}
break
case STATE.loading: switch (msg.type)
{
case MSG.finished_loading:
return [
states.loaded({
loadingStarted: model.loadingStarted,
loadingFinished: msg.now,
}),
cmds.displayPopup(`Loading finished in ${msg.now - model.loadingStarted} milliseconds!`)
]
}
break
}
return invalid_state("MainState", msg, model)
}
We're using pattern matching to process incoming messages based on their types and current state type.
In this example case we throw an error if current state cannot handle the message. You can choose to not throw an error and ignore the message instead, or handle all types of messages in all kinds of states.
Let's focus on the return value in this part of the code:
return [
states.loading({ loadingStarted: msg.now}),
cmds.startLoadingAnimation()
]
First element of the array is the updated state. States should always be immutable and it's up to you to make sure that none of the fields of the states are ever changed. Use spread syntax where applicable.
We use state constructors that were defined in step 3 of this tutorial. TypeScript checks that all of the required parameters were passed to the state context and ensures type safety.
Second element of the array (and also third and fourth and so on) is the side effect that is produced by this combination of state and Msg
. In the provided example we instruct the outer world to start loading animation. It is important to note that the state itself never starts any of the side-effects: no timers, no HTTP requests, no DOM operations etc.
Our state only instructs the outer world to run these side effects. The exact way to perform side effects is decided by the outer world.
In the real application we can handle startLoadingAnimation
by displaying an animation on the webpage.
In the tests, however, we can just skip this side effect and proceed with other messages.
Let's take a look at the full code of the example before proceeding to the outer world implementation of our state.
Complete code of the state example
import { state, cmd, msg, ExtractValues, keys, invalid_state } from "tesm"
type LoadingContext = {
loadingStarted: number
}
type AppContext = LoadingContext & {
loadingFinished: number
}
const states = state({
initial: () => ({}),
loading: <T extends LoadingContext>(m: T) => m,
loaded: <T extends AppContext>(m: T) => m,
})
export const msgs = msg({
started_loading: (now: number) => ({ now }),
finished_loading: (now: number) => ({ now }),
})
const cmds = cmd({
startLoadingAnimation: () => ({ }),
displayPopup: (text: string) => ({ text }),
})
export type Msg = ExtractValues<typeof msgs>
export type Cmd = ExtractValues<typeof cmds>
export type Model = ExtractValues<typeof states>
export const initialState = states.initial
const STATE = keys(states)
const MSG = keys(msgs)
export function update(msg: Msg, model: Model): [Model, ...Cmd[]]
{
switch (model.state)
{
case STATE.initial: switch (msg.type)
{
case MSG.started_loading:
return [
states.loading({ loadingStarted: msg.now}),
cmds.startLoadingAnimation()
]
}
break
case STATE.loading: switch (msg.type)
{
case MSG.finished_loading:
return [
states.loaded({
loadingStarted: model.loadingStarted,
loadingFinished: msg.now,
}),
cmds.displayPopup(`Loading finished in ${msg.now - model.loadingStarted} milliseconds!`)
]
}
break
}
return invalid_state("MainState", msg, model)
}
Outer world
So far we've been writing our state in full isolation: we have states, Msg, Cmd and the application logic inside update(), but it's just a bunch of pure functions and raw data.
In this section of the tutorial we get to the interesting part: integrating this pure state to the real world application full of side effects and asynchronicity.
React/Vue examples TBD
Node.js implementation
For convenience we can use a hook (not to be confused with React Hooks): an object that maintains current state and updates it when new Msg
arrive.
TESM comes with a couple of implementations of hooks, we will use the simplest one of them called createHook()
.
- Import
createHook()
and our state.
import { createHook } from "tesm"
import { update, initialState, msgs } from "./state"
- Create an instance of hook by providing it with an
update()
function of our state and with a function that returns initial state.
let hook = createHook(update)(initialState)
Now we have an object that encapsulates our application state inside itself and handles side effects.
- Add a side effect handler to the current state.
hook.addHandler(cmd =>
{
switch (cmd.type)
{
case "startLoadingAnimation":
return console.log(`loading animation started`)
case "displayPopup":
return console.log(`displaying popup with text "${cmd.text}"`)
}
})
For the purpose of this example we will just log our side effects into console. In the real world scenario we could update DOM, send HTTP requests, perform other async actions etc.
Sending new messages (Msg
) to the state is simple:
hook.send(msgs.started_loading(Date.now()))
You can construct an object manually if you want to, but it's always easier to use Msg
constructors.
Manual mode:
hook.send({ type: "started_loading", now: Date.now() })
Let's take a look at the full example:
import { createHook } from "tesm"
import { update, initialState, msgs } from "./state"
let hook = createHook(update)(initialState)
hook.addHandler(cmd =>
{
switch (cmd.type)
{
case "startLoadingAnimation":
return console.log(`loading animation started`)
case "displayPopup":
return console.log(`displaying popup with text "${cmd.text}"`)
}
})
console.log(hook.getState())
// { state: 'initial' }
hook.send(msgs.started_loading(Date.now()))
// loading animation started
console.log(hook.getState())
// { state: 'loading', loadingStarted: 1582582297994 }
hook.send(msgs.finished_loading(Date.now()))
// displaying popup with text "Loading finished in 2 milliseconds!"
console.log(hook.getState())
// { state: 'loaded', loadingStarted: 1582582297994, loadingFinished: 1582582297996 }
If you use object-oriented programming in your code and don't want to use pattern matching (switch/case) in side effect handlers, you can use ClassInterface<Cmd>
and callClass<Cmd>()
helpers:
class ConsoleLogger implements ClassInterface<Cmd>
{
startLoadingAnimation()
{
console.log(`loading animation started...`)
}
displayPopup(cmd: { text: string })
{
console.log(`displaying popup with text "${cmd.text}"`)
}
}
let hook = createHook(update)(initialState)
let logger = new ConsoleLogger()
let handler = callClass<Cmd>(logger)
hook.addHandler(cmd => handler(cmd))
hook.send(msgs.started_loading(Date.now()))
// loading animation started...
You can even make use of the existing code if you name your Cmd
identical to existing methods on your handler classes and pass the same parameters.