- 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.
- 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.
Requires Node.js 14 and up.
npm add yieldmachine
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 }
- 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.
Starts a machine, transitioning to its initially returned state.
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.
The number of times this machine has transitioned. Useful for consumers updating only when changes have been made.
The result of calling functions passed to entry()
or exit()
.
Sends an event to the machine, transitioning if the event was recognised. Unrecognised events are ignored.
Cleans up the machine.
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"
Passed as the 2nd argument to on()
to conditionally transition to an event. Can read from context to help make its decision.
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"
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"
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 }
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"
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"
});
function* AbortListener(controller: AbortController) {
function* Initial() {
yield on("abort", Aborted);
yield listenTo(controller.signal, ["abort"]);
}
function* Aborted() {}
return new Map([
[() => controller.signal.aborted, Aborted],
[null, 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 }
If you use a minifier then your function name will be changed to a short name like d
instead of On
. To get around this, you can specify your states as methods (which are not usually minified) like so:
function SwitchMachine() {
const { On, Off } = {
*Off() {
yield on("FLICK", On);
},
*On() {
yield on("FLICK", Off);
}
};
return Off;
}
- 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
- Hook for Vue
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: