Skip to content

Effects

An effect runs when a state is entered and returns an optional cleanup that runs when the state is left. Setup and teardown share one closure, so whatever the effect starts on enter is torn down by the exact cleanup that captured it.

import { machine, createStore } from '@dunky.dev/state-machine'
const tooltipStore = createStore({ openId: null as string | null })
const m = machine({
initial: 'closed',
context: { id: 'tip-1' },
states: {
open: {
effects: [
({ context, send }) =>
tooltipStore.subscribe(s => {
if (s.openId !== context.id) send({ type: 'close' })
}),
],
on: { close: { target: 'closed' } },
},
closed: {},
},
})

The store subscription is active only while the machine is in open. No manual bookkeeping: when the state exits the cleanup runs automatically.

The deciding question: does it touch the platform (DOM, native APIs) or need props?

Machine effect: props-free and platform-free. Identical on every target. Lives in the config effects array:

effects: [
({ context, send }) =>
tooltipStore.subscribe(s => {
if (s.openId !== context.id) send({ type: 'close' })
}),
]

ComponentEffect: needs the DOM or reads a prop. Declared outside the machine, passed to useMachine in your component (the tuple shape is the same on every target). A ComponentEffect is a [fn, deps] tuple: the function gets the running machine and current props, and deps names the props it reads so the bridge re-runs it only when those change:

import { type ComponentEffect } from '@dunky.dev/react-state-machine'
import type { DisclosureMachine, DisclosureProps } from './machine'
// can't live in the machine: touches the DOM and reads a prop
const onEscapeKey: ComponentEffect<DisclosureMachine, DisclosureProps> = [
(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
]
// keep the list a module constant (on React each entry becomes a hook,
// so the length must be stable across renders)
export const disclosureEffects = [onEscapeKey]

Then pass it to useMachine in your component:

import { useMachine, normalize } from '@dunky.dev/react-state-machine'
import { createDisclosureConfig, connectDisclosure } from './machine'
import { disclosureEffects } from './effects'
function Disclosure(props: DisclosureProps) {
const { api } = useMachine(createDisclosureConfig, connectDisclosure, disclosureEffects, props)
return (
<div>
<button {...normalize(api.triggerProps)}>Toggle</button>
{api.isOpen && <div {...normalize(api.panelProps)}>Content</div>}
</div>
)
}

The machine only sees send({ type: 'close' }); it has no idea an Escape key exists. On React Native the machine is unchanged; the effect swaps keydown for BackHandler. On Solid the same tuple runs as its own createEffect — no rules-of-hooks constraint, but the list stays a module constant by convention.

Reference an effect by string and define it in implementations.effects:

machine({
initial: 'open',
context: {},
states: {
open: {
effects: ['trackResize'],
on: { close: { target: 'closed' } },
},
closed: {},
},
implementations: {
effects: {
trackResize: ({ send }) => {
const handler = () => send({ type: 'resize' })
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
},
},
},
})