Examples
Check out our various examples to see how to solve problems with yay-machine
npm add yay-machine
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.
Select a card to insert
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.
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" }, }, }, },});
Drawing the state-chart on paper or a whiteboard might help to see what looks correct, and what is invalid or missing.
It looks OK so far, but it’s becoming obvious we’re going to need some external APIs such as
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>;}
export interface CashDispenser { /** * Dispenses cash to the user * @return a Promise that resolves when the cash has been dispensed */ dispenseCash(amount: number): Promise<void>;}
export interface Keypad { /** * Request the user to enter a single digit number from the available choices * @param allowed the set of allowed numbers the user may enter * @return a Promise which either resolves to the number they entered, * or rejects if they decide to cancel the transaction */ readChoice<Choice extends number>( allowed: readonly Choice[], ): Promise<Choice>;
/** * Request the user to enter a multi-digit number * @return a Promise which either resolves to the number they entered, * or rejects if they decide to cancel the transaction */ readNumber(mask: boolean): Promise<number>;}
export const BankWithdrawalErrorReason = { INVALID_CARD: "Invalid card", INVALID_PIN: "Invalid pin", INSUFFICIENT_FUNDS: "Insufficient funds",} as const;
export interface Bank { /** * Begins a cash withdrawal transaction * @return a Promise which either resolves to a transaction-ID or rejects if invalid */ beginCashWithdrawal( cardNumber: string, pin: number, amount: number, ): Promise<string>;
/** * Commits the cash withdrawal transaction with the given transaction-ID */ commitCashWithdrawn(transactionId: string): void;}
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 AtmEvent
s 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:
INCORRECT_PIN
event to the machine, orINSUFFICIENT_FUNDS
event to the machineWhen 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: "", }), }, }, },
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.
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.
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?
Examples
Check out our various examples to see how to solve problems with yay-machine