Last active
March 23, 2026 16:03
-
-
Save Skateside/65072092e74594ea357b190c57910e5c to your computer and use it in GitHub Desktop.
A simple state machine, built in TypeScript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| type IMachineSettings< | |
| TContext extends Record<string, any> = {}, | |
| TStates extends string = "", | |
| TEvents extends string = "", | |
| > = { | |
| context?: TContext, | |
| initial: TStates, | |
| states: Record<TStates, { | |
| on?: Partial<Record<TEvents, TStates>>, | |
| actions?: Partial<{ | |
| leave: (data: { | |
| context: TContext, | |
| stateName: TStates, | |
| }) => boolean | void, | |
| enter: (data: { | |
| context: TContext, | |
| stateName: TStates, | |
| previousStateName: TStates, | |
| }) => void, | |
| }>, | |
| final?: boolean, | |
| }>, | |
| }; | |
| function createMachine< | |
| TContext extends Record<string, any> = {}, | |
| TStates extends string = "", | |
| TEvents extends string = "", | |
| >( | |
| { context, initial, states }: IMachineSettings<TContext, TStates, TEvents>, | |
| ) { | |
| const data: { context: TContext, stateName: TStates, final: boolean } = { | |
| context: context ?? {} as TContext, | |
| stateName: "" as TStates, | |
| final: false, | |
| }; | |
| const isValidState = (stateName?: TStates, nextStateName?: TStates) => { | |
| if ( | |
| typeof stateName !== "string" | |
| || !Object.hasOwn(states, stateName) | |
| ) { | |
| throw new ReferenceError(`State "${stateName}" is not defined`); | |
| } | |
| return ( | |
| typeof nextStateName !== "string" | |
| || stateName !== nextStateName | |
| ); | |
| }; | |
| const getContext = (extraContext?: Partial<TContext>) => ({ | |
| ...data.context, | |
| ...(extraContext ?? {}), | |
| }); | |
| const leaveState = ( | |
| stateName: TStates, | |
| extraContext?: Partial<TContext>, | |
| ) => { | |
| if (!isValidState(stateName)) { | |
| return; // Invalid state name (should have thrown an error) | |
| } | |
| const state = states[stateName]; | |
| return state.actions?.leave?.({ | |
| stateName, | |
| context: getContext(extraContext), | |
| }); | |
| }; | |
| const enterState = ( | |
| stateName: TStates, | |
| extraContext?: Partial<TContext>, | |
| ) => { | |
| const previousStateName = data.stateName; | |
| if (!isValidState(stateName, previousStateName)) { | |
| return; // Invalid state (throws error) or already in that state. | |
| } | |
| const state = states[stateName]; | |
| data.stateName = stateName; | |
| data.final = state.final || false; | |
| state.actions?.enter?.({ | |
| stateName, | |
| previousStateName, | |
| context: getContext(extraContext), | |
| }); | |
| }; | |
| const emit = (event: TEvents, extraContext?: Partial<TContext>) => { | |
| if (data.final) { | |
| return; // In final state, can't emit anything. | |
| } | |
| const { on } = states[data.stateName]; | |
| const nextStateName = on?.[event]; | |
| if ( | |
| isValidState(nextStateName, data.stateName) | |
| && leaveState(data.stateName, extraContext) !== false | |
| && nextStateName | |
| ) { | |
| enterState(nextStateName, extraContext); | |
| } | |
| }; | |
| enterState(initial); | |
| return { | |
| get stateName() { | |
| return data.stateName; | |
| }, | |
| get final() { | |
| return data.final; | |
| }, | |
| emit, | |
| }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { computed, shallowReactive } from "vue"; | |
| export type IMachineSettings< | |
| TStates extends string = "", | |
| TEvents extends string = "", | |
| > = { | |
| initial: TStates, | |
| states: Record<TStates, { | |
| on?: Partial<Record<TEvents, TStates>>, | |
| actions?: Partial<{ | |
| leave: IMachineLeave<TStates>, | |
| enter: IMachineEnter<TEvents>, | |
| }>, | |
| }>, | |
| }; | |
| export type IMachineLeave<TStates extends string = ""> = ( | |
| { next }: { next: TStates }, | |
| ) => boolean|void; | |
| export type IMachineEnter<TEvents extends string = ""> = ( | |
| { events }: { events: TEvents[] }, | |
| ) => void; | |
| export default function createMachine< | |
| TStates extends string = "", | |
| TEvents extends string = "", | |
| >( | |
| settings: IMachineSettings<TStates, TEvents>, | |
| ) { | |
| const data = shallowReactive<{ state: TStates, events: TEvents[] }>({ | |
| state: settings.initial, | |
| events: Object.keys(settings.states[settings.initial]?.on ?? {}) as TEvents[], | |
| }); | |
| const state = computed(() => data.state); | |
| const events = computed(() => [...data.events]); | |
| if (!settings.states[data.state]) { | |
| throw new ReferenceError(`State "${data.state}" is not defined`); | |
| } | |
| const emit = (event: TEvents) => { | |
| const currentState = settings.states[data.state]; | |
| const nextStateName = currentState.on?.[event]; | |
| if (!nextStateName) { | |
| return; // Unable to find state name. | |
| } | |
| const nextState = settings.states[nextStateName]; | |
| if (!nextState) { | |
| return; // Unrecognised state | |
| } | |
| const canLeave = currentState.actions?.leave?.({ | |
| next: nextStateName, | |
| }); | |
| if (canLeave === false) { | |
| return; // User-cancelled | |
| } | |
| data.state = nextStateName; | |
| data.events = Object.keys(nextState.on || {}) as TEvents[]; | |
| nextState.actions?.enter?.({ | |
| events: data.events, | |
| }); | |
| }; | |
| return { | |
| state, | |
| events, | |
| emit, | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
emitshould check to see ifnextStateNameexists - it could be that someone is emitting the event name when the state has already changed - this should just fail rather than throwing an error