@dunky.dev/state-machine-react
v0.2.0
Published
The **React bindings** for [`@dunky.dev/state-machine`](../core/README.md). The core engine is renderer-agnostic; this package is the thin React edge that drives it: it builds the machine + connector, runs the React lifecycle, bridges the connector's snap
Downloads
94
Readme
@dunky.dev/state-machine-react
The React bindings for @dunky.dev/state-machine.
The core engine is renderer-agnostic; this package is the thin React edge that
drives it: it builds the machine + connector, runs the React lifecycle, bridges
the connector's snapshot into React rendering, translates the agnostic
bindings vocabulary into DOM
props, and owns the per-component substrate effects.
Everything here is deliberately small — the behavior lives in the core machine
and the component's connect; this layer only adapts them to React. There are
four exports: one bridge hook (useMachine, which also runs the component's
substrate effects), one leaf-subscription hook (useSelector), and two prop
helpers (normalize, mergeProps) — plus the ComponentEffect types.
useMachine — the one bridge hook
Every component's generated useXxxApi calls this with the agnostic pieces:
const { api, machine } = useMachine(
tooltipMachineConfig, // (props) => config — config factory, props seed it ONCE
connectTooltip, // pure connect(): snapshot → view api
tooltipEffects, // the component's substrate effects (ComponentEffect[])
resolved, // props with defaults applied
)It:
- builds once (in
useMemowith an empty dep array) —machine(createConfig(props))+connector(service, connect, props). The first render's props seed context and the initial state; recreating would lose state, so later prop changes flow throughsetProps, not a rebuild. - keeps props fresh via a passive effect (
connection.setProps(props)) — never during render (writing the props signal mid-render would notifyuseSyncExternalStoreand loop with "cannot update a component while rendering"). The connector was seeded with the first render's props inuseMemo, so the initial snapshot is already correct; this only pushes subsequent changes.setPropsvalue-dedups, so a consumer that rebuilds an equal props object each render doesn't churn. - runs the lifecycle:
service.start()on mount,service.stop()on unmount. The connector wired its reactions to the machine's ownstart/stop, so prop-callbacks follow automatically (StrictMode mount→unmount→mount included), with no teardown threading here. - runs the component's substrate effects — one
useEffectperComponentEffectentry, each keyed on its named prop deps (see below). The generateduseApino longer touches React directly; passing the effects list here is all it does. - drives React via
useSyncExternalStore(connection.subscribe, () => connection.snapshot)over the connector's stable, memoized snapshot — its identity only changes on a real change, so there's no infinite-loop / tearing.
Returns { api, machine }: api is the connect() output to spread onto
elements; machine is the running service (also handed to useSelector).
ComponentEffect — substrate transport, without the boilerplate
Some behavior can't live in the agnostic machine because it needs the platform
itself — a DOM keydown listener for Escape, a ResizeObserver — and the
props the machine never sees (closeOnEscape, a prevent-able
onEscapeKeyDown veto). That's the component's React-side effect.
Each effect is a [setup/teardown, depPropNames] tuple (ComponentEffect). A
component declares one named const per effect — aliasing the type once keeps the
annotations short — and exports a flat list. No React in the component file —
the generated useApi owns the useEffects:
// a target component's effects.ts (illustrative — components live outside this repo)
import type { ComponentEffect } from '@dunky.dev/state-machine-react'
type TooltipEffect = ComponentEffect<TooltipMachine, TooltipMachineProps>
/** Escape-to-close (gated by closeOnEscape; honors the onEscapeKeyDown veto). */
const trackEscape: TooltipEffect = [
(machine, props) => {
if (!props.closeOnEscape) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
// defer the decision to the agnostic resolver; act on its verdict
if (resolveEscape({ ...props, state: machine.state }).close) {
e.stopPropagation()
machine.send({ type: 'escape' })
}
}
document.addEventListener('keydown', onKeyDown, true)
return () => document.removeEventListener('keydown', onKeyDown, true)
},
['closeOnEscape', 'onEscapeKeyDown'], // ← re-run only when these props change
]
// a component with more effects adds more named consts, each with its OWN deps:
// const tabTrap: TooltipEffect = [tabFn, ['focusTrap']]
export const tooltipEffects = [trackEscape]Named consts over inline tuples: each effect gets a label (trackEscape), the
export is a flat readable list (no [[…],[…]] nesting), and each effect's deps
sit next to it. The export type is inferred (ComponentEffect[]).
useMachine runs the list — one useEffect per entry, each with a precise
dependency array built from that entry's named props (so the component file
never touches React):
// inside useMachine, after start():
// for each [fn, deps] of effects:
// useEffect(() => fn(machine, resolved), [machine, ...deps.map(k => resolved[k])])Why a list of per-effect deps (not one combined set): each effect
re-subscribes only when its own deps change — toggling focusTrap doesn't
churn the Escape listener.
Why named deps (not the whole props object): resolved is a fresh object
every render, so [machine, resolved] would re-run every render. Naming the
props — typed (keyof Props)[], so a typo is a compile error — re-runs an effect
only when one of its values actually changes, never stale. machine is always
an implicit dep.
The list must be a static module constant —
useMachinecalls one hook per entry, so its length can't vary between renders (rules-of-hooks). Declaring it asexport const xEffects = [...]guarantees that; never build it conditionally or per-render.
The agnostic decision (gate + veto) lives in the core component's resolver (
resolveEscape); only the transport (the DOM listener) is here. Same split as everywhere: agnostic policy in core, platform wiring at the edge. The machine just receives a plainescapeevent. This is the React counterpart of a coreeffect— but one that may read props and touch the DOM, which a core effect can't.
useSelector — fine-grained leaf subscription
For a leaf component that should re-render only when one slice of the machine
changes (not on every machine change) — the O(readers) path that matters at
scale (e.g. thousands of menu items, each re-rendering only when its own
highlighted state flips):
const open = useSelector(machine, () => machine.matches('open'))
const isHL = useSelector(machine, () => machine.context.highlightedValue === value)The selector reads from the machine directly, so it auto-subscribes to exactly
the fields it touches (the same auto-tracking the core's
select gives you); the
component re-renders only when the selected value changes — Object.is by
default. A selector that returns a fresh object/array each call MUST pass a
custom isEqual — otherwise every evaluation yields a new identity that
Object.is deems "changed", and useSyncExternalStore 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,
)Internally it wraps the selector in one memoized Selection and feeds it through
useSyncExternalStore, caching the value in a ref so getSnapshot stays
referentially stable between real changes.
useMachine vs. useSelector. useMachine is the per-instance bridge: it
drives the whole component off the connector's coarse snapshot (the connector
already memoizes, so a render only happens on a real change). useSelector is for
within that tree — a child that wants to re-render on just one field, decoupled
from the parent's snapshot. Reach for it when a component subtree is large enough
that whole-snapshot re-renders are wasteful.
normalize — agnostic bindings → DOM props
connect returns substrate-agnostic bindings
(onPress, describedBy, role). normalize translates them to real DOM/ARIA
props so the same connect can target DOM, React Native, or canvas — each via its
own normalize:
const domProps = normalize(api.triggerProps) // { onClick, aria-describedby, role, ... }The mapping:
| Agnostic binding | DOM/ARIA prop |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| onPress | onClick |
| onValueChange | onChange (wrapped → ChangePayload) |
| onContextMenu / onDoublePress | onContextMenu / onDoubleClick |
| onWheel / onScroll / onScrollEnd | same name (wrapped → WheelPayload / ScrollPayload) |
| onPointerEnter/Leave/Move/Down/Up/Cancel, onFocus/Blur, onKeyDown/Up | same name (already DOM-shaped) |
| describedBy / labelledBy / controls / label | aria-describedby / aria-labelledby / aria-controls / aria-label |
| expanded / selected / disabled / hidden / modal | aria-expanded / aria-selected / aria-disabled / aria-hidden / aria-modal |
| checked / pressed / current / busy / invalid / required / readOnly | matching aria-* (value untransformed) |
| valueMin/Max/Now/Text | aria-valuemin / -valuemax / -valuenow / -valuetext |
| orientation / sort / autoComplete / level / posInSet / setSize / grid col*/row* | the matching aria-* attr |
| activeDescendant / errorMessage / owns / hasPopup | aria-activedescendant / -errormessage / -owns / -haspopup |
| live / atomic | aria-live / aria-atomic |
| focusable | tabIndex (true → 0, false → -1) |
| role / id | role / id |
The logical names are renderer-neutral by design — onPress not onClick,
onValueChange not onChange, checked not aria-checked — so the same
connect output drives DOM, React Native, or canvas, each through its own
normalize. A few handlers whose agnostic payload differs from the raw event
(onValueChange/onWheel/onScroll/onScrollEnd) are wrapped so the consumer
receives the agnostic payload, not the DOM event. undefined values are
dropped, and any key not in the map passes through unchanged — so a binding the
renderer already understands needs no entry.
mergeProps — combine consumer props with the component's props
When a consumer spreads their own props onto the same element the component
controls (<Trigger onClick={mine} className="mine">), the two prop sets have to
merge sensibly. mergeProps(consumer, library) does it the Radix/Ark way:
const finalProps = mergeProps(consumerProps, normalize(api.triggerProps))- Event handlers are chained, consumer-first. Both run, the consumer's
before the library's — but if the consumer's handler marks the event
defaultPrevented, the library handler is skipped. So a consumer can veto the component's behavior (e.g. prevent a click from toggling) by callinge.preventDefault(). (A key counts as a handler when it'son+ an uppercase letter, e.g.onClick,onKeyDown.) styleis merged, not overwritten. If both sides setstyle, the result is an array[consumerStyle, libraryStyle](the React array-style form; the later entry wins on conflicting keys). If only one side sets it, that one is kept.classNameis concatenated with a single space and trimmed at the edges ('a b'+'c'→'a b c'). Inner spacing is preserved verbatim; the concat only applies when both sides are strings.- Everything else: library wins. A plain attr the component sets (
id,role,aria-*) overrides the consumer's — the component owns its semantics.
If the consumer passes no props, the library props are returned as-is.
API
| Export | What it is |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| useMachine(config, connect, effects, props) | the bridge hook — build once + lifecycle + run the component effects + useSyncExternalStore; returns { api, machine } |
| useSelector(machine, selector, isEqual?) | fine-grained subscription to a derived slice (O(readers)) |
| normalize(bindings) | agnostic bindings → DOM/ARIA props |
| mergeProps(consumer, library) | merge consumer + component props (handlers chained w/ defaultPrevented veto; style/className merged; else library wins) |
| ComponentEffect<M, P> | [ (machine, props) => cleanup, (keyof P)[] ] — one substrate effect + its prop deps |
| ComponentEffects<M, P> | ComponentEffect<M, P>[] — a component's effect list (static module constant) |
| Bindings | Record<string, unknown> — the loose shape normalize accepts |
