Skip to content

Elevator

🏷️ state data
🏷️ copy data on transition
🏷️ conditional transitions
🏷️ immediate (always) transitions
🏷️ delayed transition
🏷️ state entry side-effect
🏷️ send event to self

This example models an elevator (aka lift). This model operates completely standalone, but can also be combined into a group of coordinated elevators with the elevators controller.

The elevator machine’s state says where it is now and where it’s going, plus it has a queue of floors to visit next (if any). When a VISIT_FLOOR event is received (either by summoning it with the button in the lobby, or a passenger pressing a button on the control panel inside), the elevator either opens the doors (if already at the floor), else adds the floor to its queue.

Various states have onEntry() side-effects which ultimately use setTimeout() to send events to the machine to simulate 5 seconds to

  • open/close doors
  • move up/down one floor

This machine’s state data is homogenous and we’re using enableCopyDataOnTransition to simplify some transitions that don’t update the data.

elevatorMachine.ts
import {
type StateLifecycleSideEffectFunction,
defineMachine,
} from "yay-machine";
export interface ElevatorState {
readonly name:
| "doorsClosing"
| "doorsClosed"
| "doorsOpening"
| "doorsOpen"
| "goingUp"
| "goingDown";
readonly currentFloor: number;
readonly actionStarted: number; // performance.now()
readonly floorsToVisit: readonly number[];
}
export interface VisitFloorEvent {
readonly type: "VISIT_FLOOR";
readonly floor: number;
}
export interface CloseDoorsEvent {
readonly type: "CLOSE_DOORS";
}
export interface ClosedDoorsEvent {
readonly type: "CLOSED_DOORS";
}
export interface OpenDoorsEvent {
readonly type: "OPEN_DOORS";
}
export interface OpenedDoorsEvent {
readonly type: "OPENED_DOORS";
}
export interface ReachedNextFloorEvent {
readonly type: "REACHED_NEXT_FLOOR";
}
export type ElevatorEvent =
| VisitFloorEvent
| CloseDoorsEvent
| ClosedDoorsEvent
| OpenDoorsEvent
| OpenedDoorsEvent
| ReachedNextFloorEvent;
const sleepThen =
(
doneEvent: ElevatorEvent,
{ time = 5000, restartTimer = false } = {},
): StateLifecycleSideEffectFunction<ElevatorState, ElevatorEvent> =>
({ state, send }) => {
const elapsed = restartTimer ? 0 : performance.now() - state.actionStarted;
const delay = Math.max(time - elapsed, 0);
const timer = setTimeout(send, delay, doneEvent);
return () => {
clearTimeout(timer);
};
};
const compareFloorToCurrent = (state: ElevatorState, floor: number) =>
floor - state.currentFloor;
const insertFloor = (state: ElevatorState, floor: number): ElevatorState => {
if (state.floorsToVisit.includes(floor)) {
const e = new Error();
console.warn("looks wrong we already have floor", floor, state, e.stack);
return state;
}
const nextFloor = state.floorsToVisit[0];
let floorsToVisit = [...state.floorsToVisit, floor];
floorsToVisit.sort((a, b) => a - b);
if (nextFloor !== undefined) {
const direction = compareFloorToCurrent(state, nextFloor);
const splitIndex = floorsToVisit.findLastIndex((floor) =>
direction < 0 ? floor < state.currentFloor : floor <= state.currentFloor,
);
if (splitIndex !== -1) {
const [lowerFloors, upperFloors] = [
floorsToVisit.slice(0, splitIndex + 1),
floorsToVisit.slice(splitIndex + 1),
];
if (direction > 0) {
floorsToVisit = upperFloors.concat(lowerFloors.toReversed());
} else {
floorsToVisit = lowerFloors.toReversed().concat(upperFloors);
}
}
}
return { ...state, floorsToVisit };
};
const isAtFloor = (state: ElevatorState, floor: number) =>
floor === state.currentFloor &&
state.name !== "goingUp" &&
state.name !== "goingDown";
/**
* Models an elevator moving between floors
*/
export const elevatorMachine = defineMachine<ElevatorState, ElevatorEvent>({
enableCopyDataOnTransition: true, // most transitions don't change the state-data, so copy it by default
initialState: {
name: "doorsClosed",
currentFloor: 1,
actionStarted: -1,
floorsToVisit: [],
},
states: {
doorsClosing: {
onEnter: sleepThen({ type: "CLOSED_DOORS" }),
on: {
OPEN_DOORS: {
to: "doorsOpening",
data: ({ state }) => ({
...state,
actionStarted: 5000 - performance.now() - state.actionStarted,
floorsToVisit: state.floorsToVisit.toSpliced(0, 1),
}),
},
CLOSED_DOORS: { to: "doorsClosed" },
},
},
doorsClosed: {
on: {
OPEN_DOORS: {
to: "doorsOpening",
data: ({ state }) => ({
...state,
actionStarted: performance.now(),
floorsToVisit: state.floorsToVisit.toSpliced(0, 1),
}),
},
},
always: [
{
to: "goingUp",
when: ({ state }) =>
!!state.floorsToVisit[0] &&
state.floorsToVisit[0] > state.currentFloor,
data: ({ state }) => ({ ...state, actionStarted: performance.now() }),
},
{
to: "goingDown",
when: ({ state }) =>
!!state.floorsToVisit[0] &&
state.floorsToVisit[0] < state.currentFloor,
data: ({ state }) => ({ ...state, actionStarted: performance.now() }),
},
],
},
doorsOpening: {
onEnter: sleepThen({ type: "OPENED_DOORS" }),
on: {
OPENED_DOORS: { to: "doorsOpen" },
CLOSE_DOORS: {
to: "doorsClosing",
data: ({ state }) => ({ ...state, actionStarted: performance.now() }),
},
},
},
doorsOpen: {
onEnter: sleepThen({ type: "CLOSE_DOORS" }, { restartTimer: true }),
on: {
OPEN_DOORS: {
to: "doorsOpen",
},
VISIT_FLOOR: {
to: "doorsOpen",
when: ({ state, event }) => state.currentFloor === event.floor,
},
CLOSE_DOORS: {
to: "doorsClosing",
data: ({ state }) => ({ ...state, actionStarted: performance.now() }),
},
},
},
goingUp: {
onEnter: sleepThen({ type: "REACHED_NEXT_FLOOR" }),
on: {
REACHED_NEXT_FLOOR: [
{
to: "goingUp",
when: ({ state }) =>
state.floorsToVisit[0] !== state.currentFloor + 1,
data: ({ state }) => ({
...state,
currentFloor: state.currentFloor + 1,
actionStarted: performance.now(),
}),
},
{
to: "doorsOpening",
data: ({ state }) => ({
...state,
actionStarted: performance.now(),
currentFloor: state.floorsToVisit[0],
floorsToVisit: state.floorsToVisit.toSpliced(0, 1),
}),
},
],
},
},
goingDown: {
onEnter: sleepThen({ type: "REACHED_NEXT_FLOOR" }),
on: {
REACHED_NEXT_FLOOR: [
{
to: "goingDown",
when: ({ state }) =>
state.floorsToVisit[0] !== state.currentFloor - 1,
data: ({ state }) => ({
...state,
currentFloor: state.currentFloor - 1,
actionStarted: performance.now(),
}),
},
{
to: "doorsOpening",
data: ({ state }) => ({
...state,
actionStarted: performance.now(),
currentFloor: state.floorsToVisit[0],
floorsToVisit: state.floorsToVisit.toSpliced(0, 1),
}),
},
],
},
},
},
on: {
VISIT_FLOOR: [
{
to: "doorsOpening",
when: ({ state, event }) =>
isAtFloor(state, event.floor) && state.name !== "doorsOpening",
data: ({ state }) => ({
...state,
actionStarted: performance.now(),
floorsToVisit: state.floorsToVisit.toSpliced(0, 1),
}),
},
{
// to: *current-state*
data: ({ state, event }) => insertFloor(state, event.floor),
when: ({ state, event }) =>
!isAtFloor(state, event.floor) || state.name !== "doorsOpening",
},
],
},
});
import { elevatorMachine } from "./elevatorMachine";
const elevator = elevatorMachine.newInstance().start();
// passenger requests elevator at floor 5
elevator.send({ type: "VISIT_FLOOR", floor: 5 });
// time passes...
// ... doors open at floor 5
// passenger enters elevator and presses floor 12 button
elevator.send({ type: "VISIT_FLOOR", floor: 12 });
// time passes...
elevator.stop();