React
npm install @dunky.dev/react-state-machineyarn add @dunky.dev/react-state-machinepnpm add @dunky.dev/react-state-machinebun add @dunky.dev/react-state-machineThe React package is a thin edge layer. Behavior lives in the core machine and the component’s connect function; this package only adapts them to React: lifecycle, rendering, prop translation, and platform effects.
useMachine
Section titled “useMachine”The one bridge hook. Every component calls it with the agnostic pieces and gets back { api, machine } — the view API to render from, and the running service (for send and useSelector):
import { useMachine, normalize } from '@dunky.dev/react-state-machine'import { createDialogConfig, connectDialog, dialogEffects } from './dialog'
type DialogProps = { open?: boolean onOpenChange?: (open: boolean) => void closeOnEscape?: boolean}
function Dialog(props: DialogProps) { const { api } = useMachine( createDialogConfig, // (props) => MachineConfig; seeds context once connectDialog, // pure connect(): snapshot → view api dialogEffects, // ComponentEffect[]: DOM listeners, gated by props props, )
return ( <> <button {...normalize(api.triggerProps)}>Open</button> {api.isOpen && <div {...normalize(api.contentProps)}>Dialog content</div>} </> )}useMachine builds the machine and connector once (first render’s props seed context; later changes flow through setProps, not a rebuild), starts on mount, stops on unmount, and drives React via useSyncExternalStore over the connector’s stable snapshot.
The three imports
Section titled “The three imports”Those three values are where the dialog’s behavior actually lives, and none of it is React. You write them once, in a ./dialog module, and they run unchanged on any platform:
// dialog.ts: plain functions, no Reactimport { setup, type Connect } from '@dunky.dev/state-machine'
type State = 'closed' | 'open'type Context = {}type Event = { type: 'open' } | { type: 'close' }type Api = { isOpen: boolean triggerProps: object contentProps: object}
export const createDialogConfig = (props: DialogProps) => setup.infer().createMachine({ initial: props.open ? 'open' : 'closed', // props seed the machine ONCE context: {}, states: { closed: { on: { open: { target: 'open' } } }, open: { on: { close: { target: 'closed' } } }, }, })
export const connectDialog: Connect<State, Context, Event, DialogProps, Api> = ({ state, send,}) => ({ isOpen: state === 'open', triggerProps: { onPress: () => send({ type: 'open' }), expanded: state === 'open', }, contentProps: { role: 'dialog', modal: true },})
export const dialogEffects = [onEscapeKey]So useMachine is the only React-specific piece: createDialogConfig is the machine definition, connectDialog is the snapshot-to-view-API mapping, and dialogEffects are the DOM listeners. See Setup for configs and Connector for how connect works in depth.
normalize: bindings → DOM props
Section titled “normalize: bindings → DOM props”connect returns substrate-agnostic bindings (onPress, role, describedBy). normalize translates them to real DOM/ARIA props:
normalize(api.triggerProps)// { onClick, aria-expanded, role, tabIndex, ... }The machine binding maps handlers (onPress → onClick), ARIA props
(describedBy → aria-describedby), ARIA state (checked → aria-checked),
and focus (focusable → tabIndex). Check out the full mapping here.
undefined values are dropped. Unknown keys pass through unchanged.
mergeProps: consumer + component props
Section titled “mergeProps: consumer + component props”When a consumer spreads their own props onto the same element the component controls:
<button {...mergeProps(props, normalize(api.triggerProps))}>- Event handlers are chained, consumer-first. If the consumer calls
e.preventDefault(), the component’s handler is skipped, a clean veto. styleis merged into a[consumerStyle, libraryStyle]array.classNameis concatenated with a space.- Everything else: component wins (
id,role,aria-*).
useSelector: fine-grained subscription
Section titled “useSelector: fine-grained subscription”For a leaf component that should only re-render when one slice of the machine changes (useful when a single machine drives many rows and each row should only wake for its own value):
import { useSelector } from '@dunky.dev/react-state-machine'
function Row({ machine, value }) { const isHighlighted = useSelector(machine, () => machine.context.highlightedValue === value)
return <div data-highlighted={isHighlighted}>{value}</div>}A selector returning a fresh object/array each call must pass a custom equality function — otherwise every read is a “new” value and the component re-renders in a loop. Prefer selecting primitives; reach for isEqual when you genuinely need a composite:
const pos = useSelector( machine, () => ({ x: machine.context.x, y: machine.context.y }), (a, b) => a.x === b.x && a.y === b.y,)ComponentEffect: platform effects
Section titled “ComponentEffect: platform effects”Some behavior can’t live in the machine because it touches the DOM or reads props the machine never sees. Declare each as a [setup/teardown, depPropNames] tuple; useMachine runs one useEffect per entry, keyed on its named prop deps:
import type { ComponentEffect } from '@dunky.dev/react-state-machine'import type { DialogMachine, DialogProps } from './dialog'
type Effect = ComponentEffect<DialogMachine, DialogProps>
const onEscapeKey: Effect = [ (machine, props) => { if (!props.closeOnEscape) return const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') machine.send({ type: 'close' }) } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, ['closeOnEscape'], // re-run only when this prop changes]
export const dialogEffects = [onEscapeKey]Two rules: declare the list once, at module level — each entry becomes a useEffect, and React forbids a changing number of hooks — and name the prop deps (typed (keyof Props)[]), so each effect re-runs only when its own props change.
The machine only receives send({ type: 'close' }); it has no idea a keyboard event exists. The React Native version of this same dialog swaps the DOM keydown listener for a BackHandler — the machine is unchanged. See React Native for that side.
See Effects for the full mental model of where each type of effect lives.