/** * @file React integration for @doeixd/machine * @description * Provides a suite of hooks for integrating state machines with React components, * covering simple component state, performance-optimized selections, and advanced * framework-agnostic patterns. * * --- * * ### Hooks Overview * * 1. **`useMachine(machineFactory)`**: * - **Best for:** Local, self-contained component state. * - **Returns:** `[machine, actions]` * - The simplest way to get started. It manages an immutable machine instance * and provides a stable `actions` object to trigger transitions. * * 2. **`useMachineSelector(machine, selector, isEqual?)`**: * - **Best for:** Performance optimization in child components. * - **Returns:** A selected slice of the machine's state: `T`. * - Subscribes a component to only a part of the machine's state, preventing * unnecessary re-renders when other parts of the context change. * * 3. **`useEnsemble(initialContext, factories, getDiscriminant)`**: * - **Best for:** Coordinated machine domains, shared state, or external logic. * - **Returns:** A stable `Ensemble` instance. * - The most powerful hook. It uses the `Ensemble` pattern to decouple your * pure machine logic from React's state management, making your business * logic portable and easy to test. * * 4. **`createMachineContext()`**: * - **Best for:** Avoiding prop-drilling. * - **Returns:** A `Provider` and consumer hooks (`useContext`, `useSelector`, etc.). * - A utility to provide a machine created with `useMachine` or `useEnsemble` to * the entire component tree below it. * * 5. **`useActor(actor)`**: * - **Best for:** Using the Actor model. * - **Returns:** The current machine snapshot. */ import { useState, useRef, useMemo, createContext, useContext, createElement, useSyncExternalStore, type ReactNode, } from 'react'; import { Machine, createRunner, createEnsemble, type Ensemble, type StateStore, type Actor, type BoundTransitions, BaseMachine } from './index'; /** * Synchronous runner type used by the React hooks in this entry. * * @typeParam M - Machine or typestate union owned by the runner. */ export type Runner> = ReturnType>; type TransitionHandlers> = BoundTransitions; // ============================================================================= // HOOK 1: useMachine (Ergonomic local state) // ============================================================================= /** * A React hook for using a self-contained, immutable state machine within a component. * It provides a more ergonomic API than a raw dispatcher by returning a stable `actions` * object, similar to the `runMachine` primitive. * * This is the ideal hook for managing component-level state. * * @template M - The machine type (can be a union of states). * @param machineFactory - A function that creates the initial machine instance. * This function is called only once on the initial render. * @returns A tuple of `[machine, actions]`, where: * - `machine`: The current, reactive machine instance. Its identity changes on * every transition, triggering re-renders. Use this for reading state and * for type-narrowing. * - `actions`: A stable object containing all possible transition methods, * pre-bound to update the machine's state. */ export function useMachine>( machineFactory: () => M ): [M, TransitionHandlers] { // useState holds the machine state, triggering re-renders. const [machine, setMachine] = useState(machineFactory); // useMemo creates a stable runner instance that survives re-renders. const runner = useMemo( () => createRunner(machine, (newState) => { setMachine(newState); }), [] ); // Create a stable actions object that proxies calls to the dispatcher const actions = useMemo(() => { return new Proxy({} as any, { get: (_target, prop) => { return (...args: any[]) => { const action = runner.actions[prop as keyof typeof runner.actions]; if (typeof action !== 'function') { throw new Error(`[Machine] Transition '${String(prop)}' is not available on the current state.`); } return (action as (...args: any[]) => unknown)(...args); }; } }) as TransitionHandlers; }, [runner]); return [machine, actions]; } // ============================================================================= // HOOK 2: useMachineSelector (Performance optimization) // ============================================================================= /** * A hook that subscribes a component to a selected slice of a machine's state. * * This is a critical performance optimization. It prevents a component from * re-rendering if only an irrelevant part of the machine's context has changed. * The component will only re-render if the value returned by the `selector` function * is different from the previous render. * * @template M - The machine type. * @template T - The type of the selected value. * @param machine - The reactive machine instance from `useMachine`. * @param selector - A function that takes the current machine state and returns * a derived value. * @param isEqual - An optional function to compare the previous and next selected * values. Defaults to `Object.is` for strict equality checking. Provide your own * for deep comparisons of objects or arrays. * @returns The selected, memoized value from the machine's state. */ export function useMachineSelector, T>( machine: M, selector: (state: M) => T, isEqual: (a: T, b: T) => boolean = Object.is ): T { const selectedRef = useRef<{ initialized: boolean; value: T }>({ initialized: false, value: undefined as T, }); const nextValue = selector(machine); if (!selectedRef.current.initialized || !isEqual(selectedRef.current.value, nextValue)) { selectedRef.current = { initialized: true, value: nextValue }; } return selectedRef.current.value; } // ============================================================================= // HOOK 3: useEnsemble (Advanced integration pattern) // ============================================================================= /** * A hook that creates and manages an `Ensemble` within a React component. * * It hosts one ensemble domain in React state. Ensemble factories remain separate * from React's state management, and the stable ensemble resolves its current * machine from the latest context on every access. For application-wide * coordination between multiple ensembles, create them over the same external * store and provide them through context. * * @template C - The shared context object type. * @template F - An object of factory functions that create machine instances. * @param initialContext - The initial context object for the machine. * @param factories - An object mapping state names to factory functions. * @param getDiscriminant - An accessor function that determines the current state * from the context. * @returns A stable `Ensemble` instance. The component will reactively update * when the ensemble's underlying context changes. */ export function useEnsemble< C extends object, F extends Record Machine> >( initialContext: C, factories: F, getDiscriminant: (context: C) => keyof F ): Ensemble, C> { const [context, setContext] = useState(initialContext); const contextRef = useRef(context); contextRef.current = context; const store = useMemo>( () => ({ // getContext reads from the ref to ensure it always has the latest value, // avoiding stale closures. getContext: () => contextRef.current, setContext: (newContext) => { // The update is dispatched to React's state setter. setContext(newContext); }, }), [] // The store itself is stable and created only once. ); // The ensemble instance is also memoized to remain stable across re-renders. const ensemble = useMemo( () => createEnsemble(store, factories, getDiscriminant), [store, factories, getDiscriminant] ); return ensemble; } // ============================================================================= // UTILITY 4: createMachineContext (Dependency injection) // ============================================================================= /** * Creates a React Context for providing a machine instance down the component tree, * avoiding the need to pass it down as props ("prop-drilling"). * * It returns a `Provider` component and a suite of consumer hooks for accessing * the state and actions. * * @typeParam M - Machine or typestate union stored in React context. * @returns A Provider plus hooks for the complete value, state, actions, and selections. * @throws {Error} Consumer hooks throw when called outside the returned Provider. * @example * ```tsx * const Counter = createMachineContext(); * * function Count() { * const count = Counter.useSelector(machine => machine.context.count); * return {count}; * } * ``` */ export function createMachineContext>() { type MachineContextValue = [M, Record void>]; const Context = createContext(null); const Provider = ({ machine, actions, children, }: { machine: M; actions: Record void>; children: ReactNode; }) => { // Memoize the context value to prevent unnecessary re-renders in consumers. const value = useMemo(() => [machine, actions], [machine, actions]); return createElement(Context.Provider, { value }, children); }; const useMachineContext = (): MachineContextValue => { const context = useContext(Context); if (!context) { throw new Error('useMachineContext must be used within a Machine.Provider'); } return context; }; const useMachineState = (): M => useMachineContext()[0]; const useMachineActions = (): Record void> => useMachineContext()[1]; const useSelector = ( selector: (state: M) => T, isEqual?: (a: T, b: T) => boolean ): T => { const machine = useMachineState(); return useMachineSelector(machine, selector, isEqual); }; return { Provider, useMachineContext, useMachineState, useMachineActions, useSelector, }; } // ============================================================================= // HOOK 5: useActor (Actor Model) // ============================================================================= /** * Subscribes to an Actor and returns the current snapshot. * Uses `useSyncExternalStore` for concurrent features compatibility. * * @param actor The actor instance to subscribe to. * @returns The current machine snapshot. * @typeParam M - Machine or typestate union owned by the actor. */ export function useActor>(actor: Actor): M { // bind is important if subscribe methods rely on `this` const subscribe = useMemo(() => actor.subscribe.bind(actor), [actor]); const getSnapshot = useMemo(() => actor.getSnapshot.bind(actor), [actor]); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } /** * Subscribes to an Actor and selects a slice of the state. * Only re-renders when the selected slice changes. * * @param actor The actor instance. * @param selector Function to select a part of the state. * @param isEqual Optional equality function. * @returns The selected value, retaining reference identity while `isEqual` returns true. * @typeParam M - Machine or typestate union owned by the actor. * @typeParam T - Selected value type. */ export function useActorSelector, T>( actor: Actor, selector: (state: M) => T, isEqual: (a: T, b: T) => boolean = Object.is ): T { const selectorRef = useRef(selector); const isEqualRef = useRef(isEqual); selectorRef.current = selector; isEqualRef.current = isEqual; const subscribe = useMemo(() => actor.subscribe.bind(actor), [actor]); const getSelection = useMemo(() => { let initialized = false; let selection: T; return () => { const nextSelection = selectorRef.current(actor.getSnapshot()); if (!initialized || !isEqualRef.current(selection, nextSelection)) { initialized = true; selection = nextSelection; } return selection; }; }, [actor]); return useSyncExternalStore(subscribe, getSelection, getSelection); }