šŸ‘‘ āš™ļø Yield Machine

Define State Machines using Generator Functions

minified and gzipped size minified size zero dependencies

Install

npm add yieldmachine

start(machineDefinition: GeneratorFunction)

Starts a machine, transitioning to its initially returned state.

.current: 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.

.changeCount: number

The number of times this machine has transitioned. Useful for consumers updating only when changes have been made.

.results: Promise<unknown>

The result of any entry() or exit() messages.

.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.

enter(action: () => 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.

exit(action: () => undefined | unknown | Promise<unknown>)

Runs the provided function when this state is exited.

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)

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.current; // "initial"
button.click();
machine.current; // "clicked"
button.click();
machine.current; // "clicked"

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.current; // "idle"

loader.next("FETCH");
loader.current; // "loading"

loader.results.then((results) => {
  console.log("Fetched", results.fetchData); // Use response of fetch()
  loader.current; // "success"
});

/* Or with await: */
// const { fetchData } = await loader.results;
// loader.current; // "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.current; // "idle"

loader.next("FETCH");
loader.current; // "loading"

loader.results.then((results) => {
  console.log("Fetched", results.fetchData); // Use response of fetch()
  loader.current; // "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.current; // "initial"
aborter.abort();
machine.current; // "aborted"

TODO


Further reading / inspiration: