Skip to content

Quick Start

Installation

Terminal window
npm add yay-machine

Example: ATM

Let’s build an ATM state-machine!

To keep it simple, it will only offer a cash withdrawal service, and skip some unhappy paths.

This interactive demo is powered by the state-machine we’re going to build.

Insert card to begin

Select a card to insert

111
222
1*?

Build an outline

Let’s define all the states, events and transitions of the state-machine. Let’s ignore state-data and event-payloads right now.

This will give us an outline and we can fill in the details later.

In practice it usually takes several iterations on states and events, then machine-definition, then states and events, then machine-definition and so on, before you find something that feels right. That’s normal.

atmMachine.ts
import { defineMachine } from "yay-machine";
export interface AtmState {
readonly name:
| "waitingForCard"
| "readingCard"
| "serviceMenu"
| "enterPin"
| "enterAmount"
| "validateWithdrawal"
| "dispenseCash"
| "ejectCard";
}
export type AtmEvent = {
readonly type:
| "CARD_INSERTED"
| "CARD_READ"
| "CARD_INVALID"
| "WITHDRAWAL_SELECTED"
| "PIN_ENTERED"
| "AMOUNT_ENTERED"
| "USER_CANCELLED"
| "INCORRECT_PIN"
| "INSUFFICIENT_FUNDS"
| "WITHDRAWAL_APPROVED"
| "CASH_DISPENSED"
| "CARD_EJECTED";
};
export const atmMachine = defineMachine<AtmState, AtmEvent>({
initialState: {
name: "waitingForCard",
},
states: {
waitingForCard: {
on: {
CARD_INSERTED: { to: "readingCard" },
},
},
readingCard: {
on: {
CARD_READ: { to: "serviceMenu" },
CARD_INVALID: { to: "ejectCard" },
},
},
serviceMenu: {
on: {
USER_CANCELLED: { to: "ejectCard" },
WITHDRAWAL_SELECTED: { to: "enterPin" },
},
},
enterPin: {
on: {
USER_CANCELLED: { to: "ejectCard" },
PIN_ENTERED: { to: "enterAmount" },
},
},
enterAmount: {
on: {
USER_CANCELLED: { to: "ejectCard" },
AMOUNT_ENTERED: [
{
to: "enterAmount",
when() {
// invalid amounts
return false;
},
},
{ to: "validateWithdrawal" },
],
},
},
validateWithdrawal: {
on: {
WITHDRAWAL_APPROVED: { to: "dispenseCash" },
INCORRECT_PIN: [
{
to: "ejectCard",
when() {
// too many failed attempts
return false;
},
},
{ to: "enterPin" },
],
INSUFFICIENT_FUNDS: { to: "ejectCard" },
},
},
dispenseCash: {
always: { to: "ejectCard" },
},
ejectCard: {
on: {
CARD_EJECTED: { to: "waitingForCard" },
},
},
},
});

Try visualizing it

Drawing the state-chart on paper or a whiteboard might help to see what looks correct, and what is invalid or missing.

External APIs

It looks OK so far, but it’s becoming obvious we’re going to need some external APIs such as

  1. a card reader hardware device
  2. a cash dispenser hardware device
  3. a keypad hardware device
  4. a bank service with which we can do cash withdrawal transactions

Let’s sketch out those too:

type RemoveListener = () => void;
export interface CardReader {
/**
* Register a listener for the "card inserted" event
*/
addCardInsertedListener(callback: () => void): RemoveListener;
/**
* Reads the card-number from the currently inserted card
* @returns a Promise which resolves to the card-number,
* or rejects if the card is unreadable
*/
readCard(): Promise<string>;
/**
* Eject the card
*/
ejectCard(): Promise<void>;
}

Putting it all together

Let’s now add the state-data, event-payloads and side-effects.

First we expand the states and events so they contain the data we need to capture.

export interface AtmState {
readonly name:
| "waitingForCard"
| "readingCard"
| "serviceMenu"
| "enterPin"
| "enterAmount"
| "validateWithdrawal"
| "dispenseCash"
| "ejectCard";
readonly cardReader: CardReader;
readonly cashDispenser: CashDispenser;
readonly keypad: Keypad;
readonly bank: Bank;
readonly cardNumber: string;
readonly pin: number;
readonly withdrawalAmount: number;
readonly transactionId: string;
readonly withdrawalAttempts: number;
readonly message: string;
}
export type AtmEvent =
| {
readonly type:
| "CARD_INSERTED"
| "CARD_INVALID"
| "WITHDRAWAL_SELECTED"
| "USER_CANCELLED"
| "INCORRECT_PIN"
| "INSUFFICIENT_FUNDS"
| "CASH_DISPENSED"
| "CARD_EJECTED";
}
| {
readonly type: "CARD_READ";
readonly cardNumber: string;
}
| {
readonly type: "PIN_ENTERED";
readonly pin: number;
}
| {
readonly type: "AMOUNT_ENTERED";
readonly withdrawalAmount: number;
}
| {
readonly type: "WITHDRAWAL_APPROVED";
readonly transactionId: string;
};
export const atmMachine = defineMachine<AtmState, AtmEvent>({
initialState: {
name: "waitingForCard",
cardReader: undefined!,
cashDispenser: undefined!,
keypad: undefined!,
bank: undefined!,
cardNumber: undefined!,
pin: undefined!,
withdrawalAmount: 0,
transactionId: undefined!,
withdrawalAttempts: 0,
message: "",
},
enableCopyDataOnTransition: true,
states: {
// ...

We added enableCopyDataOnTransition: true so that TypeScript doesn’t shout at us because all our transitions are missing the data() callback.

We also set many of the default initial state-data properties to undefined! since we expect these to provided when the machine is instantiated.

Next we want to register an event listener with our card-reader device when the machine starts. When this event is received, we convert them to AtmEvents and send them to the machine instance.

Notice how the onStart() callback returns a cleanup function to remove the event-listener when the machine is stopped.

export const atmMachine = defineMachine<AtmState, AtmEvent>({
initialState: {
name: "waitingForCard",
cardReader: undefined!,
cashDispenser: undefined!,
keypad: undefined!,
bank: undefined!,
cardNumber: undefined!,
pin: undefined!,
withdrawalAmount: 0,
transactionId: undefined!,
withdrawalAttempts: 0,
message: "",
},
enableCopyDataOnTransition: true,
onStart: ({ state: { cardReader }, send }) => {
return cardReader.addCardInsertedListener(() =>
send({ type: "CARD_INSERTED" }),
);
},
states: {

Now we can define real transitions, starting from the top.

First on entering readingCard card we use an onEnter() side-effect function request the cardReader device to read the card. Depending on the result we either send the machine a CARD_READ event with the card-number, or a CARD_INVALID event. These are handled in the while still in the readingCard state and in both cases, we populate additional state-data (either the card-number or an error message), which is committed when the state changes to the next state.

states: {
waitingForCard: {
on: {
CARD_INSERTED: { to: "readingCard" },
},
},
readingCard: {
onEnter: ({ state: { cardReader }, send }) => {
cardReader.readCard().then(
(cardNumber) => send({ type: "CARD_READ", cardNumber }),
() => send({ type: "CARD_INVALID" }),
);
},
on: {
CARD_READ: {
to: "serviceMenu",
data: ({ state, event: { cardNumber } }) => ({
...state,
cardNumber,
}),
},
CARD_INVALID: {
to: "ejectCard",
data: ({ state }) => ({ ...state, message: "CARD UNREADABLE" }),
},
},
},

Next we handle the serviceMenu and enterPin states.

In serviceMenu we use an onEnter() side-effect to instruct the keypad to enable specific service-menu choices and wait for the user-selection or cancellation.

If the user selects the cash-withdrawal service, we transition to enterPin and set the withdrawalAttempts state-data to 1 because this is their first attempt.

In enterPin we use an onEnter() side-effect function to request the user enters their PIN via the keypad. Once the pin is entered, the machine transitions to enterAmount with the pin saved to state-data for later.

serviceMenu: {
onEnter: ({ state: { keypad }, send }) => {
keypad.readChoice(SERVICE_IDS).then(
(serviceId) => {
if (getService(serviceId) === "Withdraw Cash") {
send({ type: "WITHDRAWAL_SELECTED" });
}
// handle other services here
},
() => send({ type: "USER_CANCELLED" }),
);
},
on: {
USER_CANCELLED: {
to: "ejectCard",
data: ({ state }) => ({ ...state, message: "" }),
},
WITHDRAWAL_SELECTED: {
to: "enterPin",
data: ({ state }) => ({ ...state, withdrawalAttempts: 1 }),
},
},
},
enterPin: {
onEnter: ({ state: { keypad }, send }) => {
keypad.readNumber(true).then(
(pin) => send({ type: "PIN_ENTERED", pin }),
() => send({ type: "USER_CANCELLED" }),
);
},
on: {
USER_CANCELLED: {
to: "ejectCard",
data: ({ state }) => ({ ...state, message: "" }),
},
PIN_ENTERED: {
to: "enterAmount",
data: ({ state, event: { pin } }) => ({ ...state, message: "", pin }),
},
},
},

The enterAmount state is quite similar to enterPin. When the user has entered the amount, we have 3 potential transitions, the first two of which use when() to check for invalid amounts, in which case we populate the message state-data property, to show the user a friendly error.

If the amount is valid we proceed to validateWithdrawal.

enterAmount: {
onEnter: ({ state: { keypad }, send }) => {
keypad.readNumber(false).then(
(withdrawalAmount) =>
send({ type: "AMOUNT_ENTERED", withdrawalAmount }),
() => send({ type: "USER_CANCELLED" }),
);
},
on: {
USER_CANCELLED: {
to: "ejectCard",
data: ({ state }) => ({ ...state, message: "" }),
},
AMOUNT_ENTERED: [
{
to: "enterAmount",
when: ({ event: { withdrawalAmount } }) =>
withdrawalAmount % 10 !== 0,
data: ({ state }) => ({
...state,
message: "AMOUNT MUST BE MULTIPLES OF 10",
}),
},
{
to: "enterAmount",
when: ({ event: { withdrawalAmount } }) => withdrawalAmount > 250,
data: ({ state }) => ({
...state,
message: "CANNOT WITHDRAW MORE THAN 250",
}),
},
{
to: "validateWithdrawal",
data: ({ state, event: { withdrawalAmount } }) => ({
...state,
message: "",
withdrawalAmount,
}),
},
],
},
},

The validateWithdrawal state is next and the onEnter() side-effect contacts the bank to begin the cash-withdrawal transaction, sending the card-number and pin we captured earlier. If the bank approves, we send the machine a WITHDRAWAL_APPROVED with the transactionId which we store in state data when the event is handled during the transition to dispenseCash.

Also in the side-effect we’re handling some unhappy paths when the promise rejects:

  • if the bank tells us the pin was incorrect, we send an INCORRECT_PIN event to the machine, or
  • else we send an INSUFFICIENT_FUNDS event to the machine

When handling the INCORRECT_PIN we have a conditional transition to ejectCard which is taken if the user has already tried unsuccessfully 3 times. Otherwise we’ll go back to enterPin, incrementing withdrawalAttempts.

For the INSUFFICIENT_FUNDS event we just populate the message state-data property, as we’ve already done for earlier unhappy paths.

If the bank approves, we transition to dispenseCash.

validateWithdrawal: {
onEnter: ({
state: { cardNumber, pin, withdrawalAmount, bank },
send,
}) => {
bank.beginCashWithdrawal(cardNumber, pin, withdrawalAmount).then(
(transactionId) => {
send({ type: "WITHDRAWAL_APPROVED", transactionId });
},
(reason) => {
if (reason === BankWithdrawalErrorReason.INSUFFICIENT_FUNDS) {
send({ type: "INSUFFICIENT_FUNDS" });
} else {
send({ type: "INCORRECT_PIN" });
}
},
);
},
on: {
WITHDRAWAL_APPROVED: {
to: "dispenseCash",
data: ({ state, event: { transactionId } }) => ({
...state,
transactionId,
message: "CASH DISPENSING...",
}),
},
INCORRECT_PIN: [
{
to: "ejectCard",
when: ({ state }) => state.withdrawalAttempts === 3,
data: ({ state }) => ({
...state,
message: "TOO MANY FAILED ATTEMPTS\nPLEASE CONTACT BANK",
}),
},
{
to: "enterPin",
data: ({ state }) => ({
...state,
message: "INCORRECT PIN, TRY AGAIN?",
withdrawalAttempts: state.withdrawalAttempts + 1,
}),
},
],
INSUFFICIENT_FUNDS: {
to: "ejectCard",
data: ({ state }) => ({
...state,
message: "INSUFFICIENT FUNDS",
}),
},
},
},

In dispenseCash we again use an onEnter() side-effect to dispense the cash and inform the bank. We then immediately transition to ejectCard.

dispenseCash: {
onEnter: ({
state: { withdrawalAmount, transactionId, cashDispenser, bank },
}) => {
bank.commitCashWithdrawn(transactionId);
cashDispenser.dispenseCash(withdrawalAmount);
},
always: { to: "ejectCard" },
},

Finally in ejectCard we use another onEnter() side-effect to request the card-reader device to eject the card and when that completes, we return to waitingForCard and reset all the dynamic state-data for the next user-session.

ejectCard: {
onEnter: ({ state: { cardReader }, send }) => {
cardReader.ejectCard().then(() => send({ type: "CARD_EJECTED" }));
},
on: {
CARD_EJECTED: {
to: "waitingForCard",
data: ({ state }) => ({
...state,
cardNumber: undefined!,
pin: undefined!,
withdrawalAmount: 0,
transactionId: undefined!,
withdrawalAttempts: 0,
message: "",
}),
},
},
},

Using the state-machine

Now we have our definition we will want to create and run a machine instance.

import { cardReader, cashDispenser, keypad, bank } from "./apis";
const atm = atmMachine
.newInstance({
initialState: {
name: "waitingForCard",
cardReader,
cashDispenser,
keypad,
bank,
cardNumber: undefined!,
pin: undefined!,
withdrawalAmount: 0,
transactionId: undefined!,
withdrawalAttempts: 0,
message: "",
},
})
.start();
const unsubscribe = atm.subscribe(({ state, event }) => {
console.log("machine state", state, event);
});

We could also send it some events to test it

atm.send({ type: "CARD_INSERTED" });
atm.send({ type: "CARD_READ", cardNumber: "555" });

If you want to see how the demo above is implemented, the demo source code is here and the complete ATM state-machine is here.

Reflections

As with all technology there are many ways to solve the same problem.

For instance we could have used heterogenous state data since earlier states naturally have less state-data than later states.

We could have used global variables for the hardware-devices/bank-API and NOT added them to state-data. Having them in the state-data makes state-machines easier to unit test because such things can be mocked easily, but there’s no right or wrong way.

This state-machine is very much controlling the other sub-systems; it is reactive but also proactive. This isn’t always the case and you might write some state-machines that are only reactive.

Finally

This turned out to be a fairly big example, so well done for making it this far 👏.

Take a minute to think about how you would build this ATM using other programming paradigms or libraries. Would it be as concise (< 300 LoC) and expressive as the state-machine definition? Would it have the same level of correctness?

Want more examples?

Examples

Check out our various examples to see how to solve problems with yay-machine