Skip to content

Instantly share code, notes, and snippets.

@Skateside
Last active March 23, 2026 16:03
Show Gist options
  • Select an option

  • Save Skateside/65072092e74594ea357b190c57910e5c to your computer and use it in GitHub Desktop.

Select an option

Save Skateside/65072092e74594ea357b190c57910e5c to your computer and use it in GitHub Desktop.
A simple state machine, built in TypeScript
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,
};
}
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,
};
}
@netcall-jlo
Copy link
Copy Markdown

emit should check to see if nextStateName exists - 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment