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

Components for State Machines, using Generator Functions

minified and gzipped size minified size zero dependencies

Goals

Problems that state machines solve

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

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

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: