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 React component. 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
]
// list length must be stable across renders (one hook per entry)
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.

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