Skip to content

Instantly share code, notes, and snippets.

@sethdavis512
Last active May 4, 2026 13:34
Show Gist options
  • Select an option

  • Save sethdavis512/dfd2de73cdc4395e5a9a509da6087fc7 to your computer and use it in GitHub Desktop.

Select an option

Save sethdavis512/dfd2de73cdc4395e5a9a509da6087fc7 to your computer and use it in GitHub Desktop.
Lil State Machine

useStateMachine

A small typed React hook for finite state machines with guards, context, event payloads, and entry/exit actions. About 100 lines, zero dependencies.

Covers ~70% of what people reach for XState for. Stops short of hierarchical states, parallel states, and actors — when you need those, graduate to XState.

Files

  • useStateMachine.ts — the hook, reducer, and types
  • machineSpec.ts — example machine definition (connection lifecycle with retries)
  • components.tsx — Panel, Button, and the per-state view components
  • App.tsx — wires the machine to the views and simulates async connection attempts
  • index.html — entry point for running this as a standalone demo

Features

  • Guards: conditional transitions, evaluated in array order
  • Context: extended state that travels with the machine
  • Event payloads: send { type, ...data } and read it in guards and assigns
  • Entry/exit actions: side effects formalized in the spec
  • is(state) and can(event) helpers on the returned hook API
  • Type inference: state names and event types are inferred from the spec, so send("CONNETC") is a compile error

Usage

const machine = useStateMachine(machineSpec);
machine.state           // typed union of state names
machine.context         // typed context object
machine.send("EVENT")   // event type checked against spec
machine.send({ type: "CONNECTION_FAILURE", error: "..." })
machine.is("connected") // boolean
machine.can("CONNECT")  // boolean — true if event would cause a transition
import { useEffect } from "react";
import { createRoot } from "react-dom/client";
import { useStateMachine } from "./useStateMachine";
import { machineSpec } from "./machineSpec";
import { Disconnected, Connected, Connecting } from "./components";
const App = () => {
const machine = useStateMachine(machineSpec);
useEffect(() => {
if (machine.is("connecting")) {
const timeout = setTimeout(() => {
if (Math.random() < 0.5) {
machine.send({
type: "CONNECTION_FAILURE",
error: "Network timeout"
});
} else {
machine.send("CONNECTION_SUCCESS");
}
}, 1500);
return () => clearTimeout(timeout);
}
}, [machine.state]);
const views: Record<typeof machine.state, JSX.Element> = {
disconnected: (
<Disconnected
onConnect={() => machine.send("CONNECT")}
lastError={machine.context.lastError}
/>
),
connecting: (
<Connecting
retries={machine.context.retries}
maxRetries={machine.context.maxRetries}
/>
),
connected: <Connected onDisconnect={() => machine.send("DISCONNECT")} />
};
return <div>{views[machine.state]}</div>;
};
const container = document.getElementById("app");
if (container) {
createRoot(container).render(<App />);
}
import type { ReactNode, ButtonHTMLAttributes } from "react";
type PanelProps = {
children: ReactNode;
title: string;
};
export const Panel = ({ children, title }: PanelProps) => (
<div className="box m-4">
<h2 className="title is-2">{title}</h2>
{children}
</div>
);
type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> & {
className: string;
handleClick?: () => void;
};
export const Button = ({ className, handleClick, children, ...rest }: ButtonProps) => (
<button className={`button ${className}`} onClick={handleClick} {...rest}>
{children}
</button>
);
type DisconnectedProps = {
onConnect: () => void;
lastError: string | null;
};
export const Disconnected = ({ onConnect, lastError }: DisconnectedProps) => (
<Panel title="Disconnected">
{lastError && <p className="has-text-danger mb-3">Last error: {lastError}</p>}
<Button className="is-success" handleClick={onConnect}>
Connect
</Button>
</Panel>
);
type ConnectedProps = {
onDisconnect: () => void;
};
export const Connected = ({ onDisconnect }: ConnectedProps) => (
<Panel title="Connected">
<Button className="is-danger" handleClick={onDisconnect}>
Disconnect
</Button>
</Panel>
);
type ConnectingProps = {
retries: number;
maxRetries: number;
};
export const Connecting = ({ retries, maxRetries }: ConnectingProps) => (
<Panel title="Connecting">
<p className="mb-3">
Attempt {retries + 1} of {maxRetries}
</p>
<Button className="is-default" disabled>
Please wait...
</Button>
</Panel>
);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>useStateMachine demo</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/App.tsx"></script>
</body>
</html>
import type { MachineSpec } from "./useStateMachine";
export type ConnectionState = "disconnected" | "connecting" | "connected";
export type ConnectionContext = {
retries: number;
maxRetries: number;
lastError: string | null;
};
export type ConnectionEvent =
| { type: "CONNECT" }
| { type: "CONNECTION_SUCCESS" }
| { type: "CONNECTION_FAILURE"; error: string }
| { type: "DISCONNECT" };
export const machineSpec: MachineSpec<ConnectionContext, ConnectionState, ConnectionEvent> = {
initialState: "disconnected",
initialContext: {
retries: 0,
maxRetries: 3,
lastError: null
},
states: {
disconnected: {
on: {
CONNECT: { target: "connecting" }
}
},
connecting: {
onEntry: (context) => {
console.log(`Attempting connection (try ${context.retries + 1})`);
},
onExit: () => {
console.log("Leaving connecting state");
},
on: {
CONNECTION_SUCCESS: {
target: "connected",
assign: () => ({ retries: 0, lastError: null })
},
CONNECTION_FAILURE: [
{
target: "connecting",
guard: (context) => context.retries < context.maxRetries - 1,
assign: (context, event) => ({
retries: context.retries + 1,
lastError: event.error
})
},
{
target: "disconnected",
assign: (_context, event) => ({
retries: 0,
lastError: event.error
})
}
]
}
},
connected: {
onEntry: () => console.log("Connected!"),
on: {
DISCONNECT: { target: "disconnected" }
}
}
}
};
import { useReducer, useEffect, useMemo, useRef } from "react";
// Base shape of an event — must have a string type, payload is open
export type EventObject = { type: string; [key: string]: unknown };
// What a transition looks like in the spec
export type Transition<TContext, TState extends string, TEvent extends EventObject> = {
target: TState;
guard?: (context: TContext, event: TEvent) => boolean;
assign?: (context: TContext, event: TEvent) => Partial<TContext>;
};
// A state node holds entry/exit actions and a map of event types to transitions
export type StateNode<TContext, TState extends string, TEvent extends EventObject> = {
onEntry?: (context: TContext, send: (event: TEvent | TEvent["type"]) => void) => void;
onExit?: (context: TContext, send: (event: TEvent | TEvent["type"]) => void) => void;
on?: {
[K in TEvent["type"]]?:
| Transition<TContext, TState, Extract<TEvent, { type: K }>>
| Transition<TContext, TState, Extract<TEvent, { type: K }>>[];
};
};
// The full spec
export type MachineSpec<TContext, TState extends string, TEvent extends EventObject> = {
initialState: TState;
initialContext: TContext;
states: { [K in TState]: StateNode<TContext, TState, TEvent> };
};
// Internal reducer state
type MachineState<TContext, TState extends string, TEvent extends EventObject> = {
state: TState;
context: TContext;
_lastEvent: TEvent | null;
_prevState: TState | null;
};
// Normalize a transition definition to an array so we can iterate uniformly
const toTransitions = <T,>(transition: T | T[] | undefined): T[] => {
if (transition === undefined) return [];
return Array.isArray(transition) ? transition : [transition];
};
// Pick the first transition whose guard passes (or has no guard)
const resolveTransition = <TContext, TState extends string, TEvent extends EventObject>(
transitions: Transition<TContext, TState, TEvent>[],
context: TContext,
event: TEvent
): Transition<TContext, TState, TEvent> | undefined => {
return transitions.find((t) => !t.guard || t.guard(context, event));
};
const buildMachineReducer =
<TContext, TState extends string, TEvent extends EventObject>(
spec: MachineSpec<TContext, TState, TEvent>
) =>
(
current: MachineState<TContext, TState, TEvent>,
event: TEvent | TEvent["type"]
): MachineState<TContext, TState, TEvent> => {
// Normalize bare string events to { type } so payloads work uniformly
const evt = (typeof event === "string" ? { type: event } : event) as TEvent;
const stateNode = spec.states[current.state];
if (stateNode === undefined) {
throw new Error(`No state node defined for "${current.state}"`);
}
const transitions = toTransitions(stateNode.on?.[evt.type as TEvent["type"]]) as Transition
TContext,
TState,
TEvent
>[];
const transition = resolveTransition(transitions, current.context, evt);
if (transition === undefined) {
if (process.env.NODE_ENV !== "production") {
console.warn(`Ignored event "${evt.type}" in state "${current.state}"`);
}
return current;
}
const nextContext = transition.assign
? { ...current.context, ...transition.assign(current.context, evt) }
: current.context;
return {
state: transition.target,
context: nextContext,
_lastEvent: evt,
_prevState: current.state
};
};
export const useStateMachine = <TContext, TState extends string, TEvent extends EventObject>(
spec: MachineSpec<TContext, TState, TEvent>
) => {
const reducer = useMemo(() => buildMachineReducer(spec), [spec]);
const [machine, dispatch] = useReducer(reducer, {
state: spec.initialState,
context: spec.initialContext,
_lastEvent: null,
_prevState: null
} as MachineState<TContext, TState, TEvent>);
// Stable send reference that always points at the latest dispatch
const sendRef = useRef<(event: TEvent | TEvent["type"]) => void>(() => {});
sendRef.current = (event) => {
dispatch(typeof event === "string" ? ({ type: event } as TEvent) : event);
};
const send = useMemo(
() => (event: TEvent | TEvent["type"]) => sendRef.current(event),
[]
);
// Fire entry/exit actions when the state changes
useEffect(() => {
if (machine._prevState && machine._prevState !== machine.state) {
spec.states[machine._prevState]?.onExit?.(machine.context, send);
}
spec.states[machine.state]?.onEntry?.(machine.context, send);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [machine.state]);
return {
state: machine.state,
context: machine.context,
send,
is: (s: TState) => machine.state === s,
can: (eventType: TEvent["type"]) => {
const transitions = toTransitions(
spec.states[machine.state]?.on?.[eventType]
) as Transition<TContext, TState, TEvent>[];
return (
resolveTransition(transitions, machine.context, { type: eventType } as TEvent) !==
undefined
);
}
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment