|
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 |
|
); |
|
} |
|
}; |
|
}; |