Goals
- States and machines can be reused ā components for state machines.
- Nest machines inside one another āĀ aid reuse and clarity.
- Interops with native JavaScript & browser features such as Promise, AbortSignal, and EventTarget.
- Consistently use built-in browser features such as offline status, promises, fetch, IntersectionObserver, ResizeObserver, window.location. Manage these things in a consistent way with a consistent interface.
Problems that state machines solve
- Making sure my code is 100% robust and doesn't fall into inconsistent states is hard.
- It's easy to forget about error handling.
- Built-in browser features (such as InteractionObserver) are powerful but a pain to manage correctly.
- Managing various flavors of state is hard: the current URL, local storage, focused element, fetch response, caches, offline/online.
Install
Requires Node.js 14 and up.
npm add yieldmachine
Overview
You define your machine using a function. For example, you could define a state machine representing a light switch. Weāll name our function Switch
.
function Switch() {
}
Inside you declare each state you want as a generator function.
Our Switch
will have two states: Off
and On
. We return Off
as thatās what we want as our initial state to be ā our light is off by default.
import { on, start } from "yieldmachine";
function Switch() {
function* Off() {
}
function* On() {
}
return Off;
}
Our Switch
can be flicked on and off. The string "FLICK"
is our event that will represent the flicking on and off of our switch.
When our Switch
is Off
and it is sent a FLICK
event, it transitions to On
.
And, when our Switch
is On
and it is sent a FLICK
event, it transitions back to Off
.
import { on, start } from "yieldmachine";
function Switch() {
function* Off() {
yield on("FLICK", On);
}
function* On() {
yield on("FLICK", Off);
}
return Off;
}
Now our machine is ready to be run. We pass our Switch
to the start
function we import from yieldmachine
, and it will run an instance of our machine. And as we send it "FLICK"
message, youāll see the value
of our machine instance change.
const machine = start(Switch);
machine.value; // { state: "Off", change: 0 }
machine.next("FLICK");
machine.value; // { state: "On", change: 1 }
machine.next("FLICK");
machine.value; // { state: "Off", change: 2 }
Benefits of Generator Functions
- Generator Functions are a built-in feature of JavaScript and TypeScript.
- They have built-in syntax highlighting, autocompletion, and general rich language support in editors like Visual Studio Code.
- Our states are represented by actual JavaScript functions.
- This means if we pass a state thatās either spelled incorrectly or isnāt in scope, our editor will tell us.
- Our states use the name of the function.
- Generator Functions can be reused, composed, and partially applied like any function. This increases the modularity and reuse of our machine parts.
- Coming soon: our machine definitions can be serialized and deserialized. This means they could be generated on a back-end and sent to the front-end. They could be stored away in a database. They could even be generated dynamically on the fly.
Documentation
start(machineDefinition: Function | GeneratorFunction, options: { signal?: AbortSignal })
Starts a machine, transitioning to its initially returned state.
.value
.value.state: string | Record<string, unknown>
The current state of the machine. If machines were nested then an object is returned with the parent machine as the key, and its current state as the value.
.value.change: number
The number of times this machine has transitioned. Useful for consumers updating only when changes have been made.
.value.results: Promise<unknown>
The result of calling functions passed to entry()
or exit()
.
.next(eventName: string | symbol)
Sends an event to the machine, transitioning if the event was recognised. Unrecognised events are ignored.
.stop()
Cleans up the machine.
Messages
on(eventName: string | symbol, target: GeneratorFunction)
Transitions to the target state when the given event occurs.
import { on, start } from "yieldmachine";
function Switch() {
function* Off() {
yield on("FLICK", On);
yield on("TOGGLE", On);
}
function* On() {
yield on("FLICK", Off);
yield on("TOGGLE", Off);
}
return Off;
}
const machine = start(Switch);
machine.value.state; // "Off"
machine.next("FLICK");
machine.value.state; // "On"
machine.next("TOGGLE");
machine.value.state; // "Off"
entry(action: ({ signal }: { signal: AbortSignal }) => undefined | unknown | Promise<unknown>)
Runs the provided function when this state is entered. If the function returns a promise, its value is made available in the .results
property of the machine, keyed by the name of this passed function.
A signal is provided which is useful for passing to fetch()
or eventTarget.addEventListener()
. This signal is aborted on exit.
import { start, on, enter } from "yieldmachine";
let onCount = 0;
function recordOn() {
onCount++;
}
function Switch() {
function* Off() {
yield on("FLICK", On);
}
function* On() {
yield entry(recordOn);
yield on("FLICK", Off);
}
return Off;
}
const machine = start(Switch);
machine.next("FLICK");
console.log(onCount, machine.value.state); // 1, "ON"
machine.next("FLICK");
console.log(onCount, machine.value.state); // 1, "OFF"
machine.next("FLICK");
console.log(onCount, machine.value.state); // 2, "ON"
exit(action: () => undefined | unknown | Promise<unknown>)
Runs the provided function when this state is exited.
import { start, on, exit } from "yieldmachine";
let lastSessionEnded = null;
function recordSessionEnd() {
lastSessionEnded = new Date();
}
function Session() {
function* SignedOut() {
yield on("AUTHENTICATE", SignedIn);
}
function* SignedIn() {
yield exit(recordSessionEnd);
yield on("LOG_OFF", SignedOut);
}
return SignedOut;
}
const machine = start(Switch);
console.log(lastSessionEnded, machine.value.state); // null, "SignedOut"
machine.next("AUTHENTICATE");
console.log(lastSessionEnded, machine.value.state); // null, "SignedIn"
machine.next("LOG_OFF");
console.log(lastSessionEnded, machine.value.state); // (current time), "SignedOut"
cond(predicate: () => boolean, target: GeneratorFunction)
Immediately transitions to the target state if the provided predicate function returns true
.
always(target: GeneratorFunction)
Immediately transitions to the target state, if previous cond()
did not pass.
listenTo(sender: EventTarget, eventName: string | string[])
Listens to an EventTarget
ā for example, an HTMLElement like a button.
Uses .addEventListener()
to listen to the event. The listener is removed when transitioning to a different state or when the machine is stopped, so no extra clean up is necessary.
function ButtonClickListener(button: HTMLButtonElement) {
function* initial() {
yield on("click", clicked);
yield listenTo(button, "click");
}
function* clicked() {}
return initial;
}
const button = document.createElement('button');
const machine = start(ButtonClickListener.bind(null, button));
machine.value; // { state: "initial", change: 0 }
button.click();
machine.value; // { state: "clicked", change: 1 }
button.click();
machine.value; // { state: "initial", change: 1 }
Examples
HTTP Loader
import { entry, on, start } from "yieldmachine";
const exampleURL = new URL("https://example.org/");
function fetchData() {
return fetch(exampleURL);
}
// Define a machine just using functions
function Loader() {
// Each state is a generator function
function* idle() {
yield on("FETCH", loading);
}
// This is the āloadingā state
function* loading() {
// This function will be called when this state is entered.
// Its return value is available at `loader.results.fetchData`
yield entry(fetchData);
// If the promise succeeds, we will transition to the `success` state
// If the promise fails, we will transition to the `failure` state
yield on("SUCCESS", success);
yield on("FAILURE", failure);
}
// States that donāt yield anything are final
function* success() {}
// Or they can define transitions to other states
function* failure() {
// When the RETRY event happens, we transition from āfailureā to āloadingā
yield on("RETRY", loading);
}
// Return the initial state from your machine definition
return idle;
}
const loader = start(Loader);
loader.value; // { state: "idle", change: 0 }
loader.next("FETCH");
loader.value; // { state: "loading", change: 1, results: Promise }
loader.value.results.then((results) => {
console.log("Fetched", results.fetchData); // Use response of fetch()
loader.value.state; // "success"
});
/* Or with await: */
// const { fetchData } = await loader.value.results;
// loader.value.state; // "success"
Passing parameters to a machine with closures
import { entry, on, start } from "yieldmachine";
// Function taking as many arguments as you like
function GenericLoader(url) {
function fetchData() {
return fetch(url);
}
function* idle() {
yield on("FETCH", loading);
}
function* loading() {
yield entry(fetchData);
yield on("SUCCESS", success);
yield on("FAILURE", failure);
}
function* success() {}
function* failure() {
yield on("RETRY", loading);
}
return idle;
}
// Function taking no arguments that will define our machine
function SpecificLoader() {
const exampleURL = new URL("https://example.org/");
return GenericLoader(exampleURL);
}
// Start our specific loader machine
const loader = start(SpecificLoader);
loader.value; // { state: "idle", change: 0 }
loader.next("FETCH");
loader.value; // { state: "loading", change: 1, results: Promise }
loader.value.results.then((results) => {
console.log("Fetched", results.fetchData); // Use response of fetch()
loader.value.state; // "success"
});
AbortController
wrapper
function AbortListener(controller: AbortController) {
function* initial() {
if (controller.signal.aborted) {
yield always(aborted);
} else {
yield on("abort", aborted);
yield listenTo(controller.signal, "abort");
}
}
function* aborted() {}
return initial;
}
const aborter = new AbortController();
const machine = start(AbortListener.bind(null, aborter));
machine.value; // { state: "initial", change: 0 }
aborter.abort();
machine.value; // { state: "aborted", change: 1 }
TODO
- Parallel states by returning object for initial state
- Assign data somehow?
- Allow sending objects:
Event | { type: string }
- More examples!
- Hook for React
- Hook for Preact
function *Parallel() {
function Light1() {
function* Off() {
yield on('toggle_switch_1', On);
}
function* On() {
yield on('toggle_switch_1', Off);
}
return Off;
}
function Light2() {
function* Off() {
yield on('toggle_switch_2', On);
}
function* On() {
yield on('toggle_switch_2', Off);
}
return Off;
}
return [
Light1,
Light2
];
}
function *ParallelWithANDState() {
function Light1() {
function* Off() {
yield on('toggle_switch_1', On);
}
function* On() {
yield on('toggle_switch_1', Off);
}
return Off;
}
function Light2() {
function* Off() {
yield on('toggle_switch_2', On);
}
function* On() {
yield on('toggle_switch_2', Off);
}
return Off;
}
// function* Light3() {
// function* Off() {}
// function* On() {}
// return conds(new Map([
// [hasState(Light1, 'Off'), Off],
// [hasState(Light2, 'Off'), Off],
// [true, On],
// ]));
// }
function* Light3() {
const light1Off = yield readHasState(Light1, 'Off');
const light2Off = yield readHasState(Light2, 'Off');
function* Off() {}
function* On() {}
function* checking() {
yield cond(light1Off, Off);
yield cond(light2Off, Off);
yield always(On);
}
return checking;
}
// Alternative
function* Light3() {
const onCount = yield readCountSiblings('Off');
function* Off() {}
function* On() {}
function* checking() {
yield cond(onCount === 2, Off);
// yield cond(`${onCount} === ${2}`, Off);
// yield condIs(Off, onCount, 2);
yield always(On);
}
return checking;
}
return [
Light1,
Light2,
Light3
];
}
Further reading / inspiration: