Basic Usage
Minimal Example
import { machine } from '@vielzeug/clockwork';
type Event = { type: 'TOGGLE' };
const m = machine({
initial: 'on',
states: {
off: { on: { TOGGLE: { target: 'on' } } },
on: { on: { TOGGLE: { target: 'off' } } },
},
});
console.log(m.send({ type: 'TOGGLE' })); // 'transitioned' — state changes to 'off'With Context
Context holds data that changes during transitions. Mutate it directly inside action functions:
import { machine } from '@vielzeug/clockwork';
type Event = { type: 'DEC' } | { type: 'INC' };
const m = machine({
context: { count: 0 },
initial: 'idle',
states: {
idle: {
on: {
DEC: {
actions: [
({ context }) => {
context.count -= 1;
},
],
target: 'idle',
},
INC: {
actions: [
({ context }) => {
context.count += 1;
},
],
target: 'idle',
},
},
},
},
});Context is required when non-empty
When your context type has properties (e.g. { count: number }), context is a required field. For stateless machines, omit context entirely.
Transition Syntax
Shorthand — single transition
When there is exactly one possible transition for an event, pass it directly as an object:
states: {
idle: {
on: {
GO: { target: 'active' },
},
},
}Array — multiple guarded transitions
When multiple transitions are possible (processed in order, first guard wins), use an array:
states: {
checkout: {
on: {
PAY: [
{
guard: ({ context }) => context.balance >= context.total,
actions: [({ context }) => { context.balance -= context.total; }],
target: 'success',
},
{
// fallback when guard fails
target: 'insufficient_funds',
},
],
},
},
}Guards
Guards decide whether a transition occurs based on current context and the event payload.
type Event = { type: 'SUBMIT'; value: number };
states: {
form: {
on: {
SUBMIT: {
guard: ({ event }) => event.value > 0,
actions: [({ context, event }) => { context.input = event.value; }],
target: 'processing',
},
},
},
}TIP
Guards must be pure functions. Side effects belong in actions, not guard.
Actions
Actions run during transitions to update context. Multiple actions execute in order. Actions receive a mutable context draft and the readonly event:
import type { ActionFn } from '@vielzeug/clockwork';
const logEvent: ActionFn<Context, Event> = ({ context, event }) => {
context.lastEvent = event.type;
context.updatedAt = Date.now();
};
// Use in transition:
actions: [logEvent, ({ context }) => { context.processed = true; }],For partial shallow-merge style updates, spread into the context:
actions: [({ context, event }) => {
Object.assign(context, { count: context.count + event.amount, updatedAt: Date.now() });
}],Entry and Exit Actions
entry and exit run when a state is entered or left. Both fire on self-transitions too. They receive { context, event } where event may be a LifecycleEvent ($init, $hydrate, or $after):
states: {
active: {
entry: ({ context }) => {
context.startTime = Date.now();
},
exit: ({ context }) => {
context.duration = Date.now() - context.startTime;
},
on: {
STOP: { target: 'idle' },
},
},
}Async Invokes
Invokes run a Promise when a state is entered and dispatch an event when it settles.
Basic invoke
onDone and onError receive (result, context) — context is a snapshot from when the invoke started, not the live value at resolution time.
states: {
loading: {
invoke: [
{
id: 'fetch-items', // optional; shown in debug events as invokeId
src: async ({ signal }) => fetch('/api/data', { signal }).then(r => r.json()),
onDone: (result, _ctx) => ({ type: 'DATA_READY', data: result }),
onError: (error, _ctx) => ({ type: 'DATA_ERROR', message: String(error) }),
},
],
on: {
DATA_READY: { actions: [({ context, event }) => { context.data = event.data; }], target: 'idle' },
DATA_ERROR: { target: 'error' },
},
},
}Cancellation with AbortSignal
Invokes are automatically aborted when the state is exited. Pass signal to fetch or any AbortSignal-aware API:
src: async ({ context, signal }) => {
const response = await fetch(`/api/${context.userId}/items`, { signal });
return response.json();
},Multiple invokes
A state can run multiple invokes in parallel. Each invoke can have an optional id string that appears as invokeId in debug events:
invoke: [
{ id: 'user', src: async () => fetchUser(), onDone: (user, _ctx) => ({ type: 'USER_LOADED', user }) },
{ id: 'perms', src: async () => fetchPermissions(), onDone: (perms, _ctx) => ({ type: 'PERMS_LOADED', perms }) },
],Delayed Transitions (After)
Schedule automatic transitions after a delay. After-timers are cancelled when the state is exited.
Basic delay
states: {
notification: {
after: [{ delay: 5000, target: 'dismissed' }],
},
dismissed: {},
}With guard and actions
states: {
idle: {
after: [
{
delay: 3000,
guard: ({ context }) => context.autoClose,
actions: [({ context }) => { context.closedAt = Date.now(); }],
target: 'closed',
},
],
},
}After + Invoke interaction
When a state has both after and invoke, the after-timer is cleared if the state is exited via an invoke result:
states: {
loading: {
after: [{ delay: 10000, target: 'timeout' }],
invoke: [{
src: async ({ signal }) => fetch('/api', { signal }).then(r => r.json()),
onDone: (data) => ({ type: 'DONE', data }),
}],
on: { DONE: { target: 'success' } },
},
timeout: {},
success: {},
}Hierarchical States
Compound states contain nested substates. A compound state must have an initial property pointing to one of its children.
Basic hierarchy
import { machine } from '@vielzeug/clockwork';
const m = machine({
context: { mode: '' },
initial: 'active',
states: {
idle: {},
active: {
initial: 'editing',
states: {
editing: { on: { SAVE: { target: 'active.saving' } } },
saving: { on: { DONE: { target: 'idle' } } },
},
},
},
});
// Entering 'active' automatically resolves to 'active.editing'
console.log(m.state.value); // 'active.editing'Leaf resolution
When targeting a compound state, the machine automatically descends to the deepest initial leaf:
// target: 'active' resolves to 'active.editing'
on: {
RESTART: {
target: 'active';
}
}matches() with hierarchy
matches() returns true for ancestor states too:
m.matches('active'); // true when in 'active.editing' or 'active.saving'
m.matches('active.editing'); // true only when in 'active.editing'Context Validation
Validate context at initialization and on every transition:
import { machine } from '@vielzeug/clockwork';
const m = machine({
context: { count: 0, name: 'app' },
initial: 'idle',
validateContext: (ctx) => typeof ctx.count === 'number' && typeof ctx.name === 'string',
states: { idle: {} },
});When validation fails, a MachineError with code MACHINE_INVALID_VALIDATE_CONTEXT is thrown. The machine state and context are unchanged — the transition is rolled back before any signals are updated.
Persistence
Save and restore machine state across sessions using a persistence adapter.
Local Storage
import { machine, type MachineSnapshot } from '@vielzeug/clockwork';
const m = machine(config, {
persistence: {
load: () => {
const raw = localStorage.getItem('clockwork:state');
return raw ? (JSON.parse(raw) as MachineSnapshot<State, Context>) : undefined;
},
save: (snapshot) => {
localStorage.setItem('clockwork:state', JSON.stringify(snapshot));
},
},
});Hydration behavior
On startup, machine() checks options.snapshot first, then persistence.load(). If a persisted snapshot exists, the machine hydrates from it — entry hooks run, invokes start, and after-timers schedule.
Disposal does not clear persistence
m[Symbol.dispose]() does not clear persisted state. The machine may be recreated (e.g. after HMR or component remount) and should resume from the last saved state. To reset persistence, call your adapter's storage API directly.
Validate loaded snapshots
If context is loaded from untrusted sources (e.g. localStorage), run your validateContext guard before interpreting, or wrap persistence.load() with a try/catch and schema check.
Interceptors
Interceptors are pure functions that run before event processing. Return the event (optionally transformed) to allow it, or null to block it. They run left-to-right — the first null stops the chain:
import { machine, type InterceptorFn } from '@vielzeug/clockwork';
const logger: InterceptorFn<State, Context, Event> = (event, snapshot) => {
console.log(`[${snapshot.state}] ${event.type}`);
return event; // pass through
};
const blocker: InterceptorFn<State, Context, Event> = (event, _snapshot) => {
if (event.type === 'BLOCKED') return null; // swallow event
return event;
};
const m = machine(config, { interceptors: [logger, blocker] });TIP
Interceptors can also transform events — return a modified event object to change its type or payload before it reaches the machine.
send() and SendResult
send() returns a SendResult string, not a boolean:
const result = m.send({ type: 'GO' });
// 'transitioned' — a transition occurred
// 'queued' — called re-entrantly from inside an action
// 'rejected' — no match, guard failed, interceptor blocked, or machine disposedUse this for conditional feedback or analytics:
if (m.send({ type: 'SUBMIT' }) === 'rejected') {
showError('Action not allowed in current state');
}Checking State
matches() — check multiple states at once
m.matches('idle'); // true if current state is 'idle'
m.matches('loading', 'error'); // true if in either state (or a child of either)can() — check if an event would be accepted
m.can({ type: 'SUBMIT' }); // true if a valid transition exists for SUBMIT in the current stateTIP
can() evaluates guards against the current context but does not fire any debug hooks. It is a pure read — use it freely for UI conditional rendering.
Subscribe
Subscribe to state/context changes without using @vielzeug/ripple directly:
const unsub = m.subscribe(({ state, context }) => {
renderUI(state, context);
});
m.send({ type: 'INC' }); // subscriber fires
unsub(); // stop listeningThe callback fires only when state or context reference changes — not on every signal read.
Debugging and Tracing
For quick console-based debugging, use debugInterpret from the dedicated sub-path. It pre-wires onDebug and onTransition to console.debug/console.group and is tree-shaken from production bundles.
Development only
debugInterpret writes event payloads — including full event objects and context — to the console. Do not use it in production if events or context contain PII.
import { debugInterpret } from '@vielzeug/clockwork/devtools';
const m = debugInterpret(machine);
m.send({ type: 'START' });
// [clockwork:transition] START: idle → active
// [clockwork:guard] START: idle → active — passedFor custom handling, pass debug options directly to machine() or define().start().
Debug events
The onDebug callback receives a discriminated union of debug events:
const m = machine(config, {
debug: {
onDebug: (event) => {
switch (event.type) {
case 'guard':
console.log(`Guard: ${event.from} → ${event.target} = ${event.passed}`);
break;
case 'transition-skipped':
console.log(`${event.event.type} in ${event.from}: no matching transition`);
break;
case 'invoke-start':
console.log(`invoke #${event.invokeId} started in ${event.state}`);
break;
case 'invoke-done':
console.log(`invoke #${event.invokeId} done:`, event.result);
break;
case 'invoke-error':
console.error(`invoke #${event.invokeId} failed:`, event.error);
break;
case 'invoke-abort':
console.log(`invoke #${event.invokeId} aborted`);
break;
}
},
},
});onTransition callback
For lightweight observation without full debug events:
const m = machine(config, {
debug: {
onTransition: ({ from, to, event }) => {
analytics.track('state_change', { from, to, event: event.type });
},
},
});Transition trace buffer
When onDebug or onTransition is set, a 50-entry trace buffer is enabled automatically. Set traceLimit to control size (0 disables):
const m = machine(config, { debug: { onTransition: () => {}, traceLimit: 200 } });
m.send({ type: 'GO' });
m.send({ type: 'BACK' });
console.log(m.getTrace());
// [
// { from: 'idle', to: 'active', event: { type: 'GO' }, timestamp: 1234567890 },
// { from: 'active', to: 'idle', event: { type: 'BACK' }, timestamp: 1234567891 },
// ]When the buffer is full, the oldest entry is overwritten. The array returned by getTrace() is always in chronological order. Each call returns fresh cloned entries — mutating them does not affect the internal buffer.
Testing
Pure transition resolution
resolveTransition() is a pure function — it resolves which TransitionDef would apply without running any actions, entry/exit handlers, or invokes. Pass the config object directly:
import { resolveTransition } from '@vielzeug/clockwork';
const config = {
/* machine config */
};
const transition = resolveTransition(config, {
context: { authorized: false },
event: { type: 'LOGIN' },
state: 'idle',
});
expect(transition).toBeUndefined(); // guard failed
const passing = resolveTransition(config, {
context: { authorized: true },
event: { type: 'LOGIN' },
state: 'idle',
});
expect(passing?.target).toBe('dashboard');Snapshot testing
const m = machine(config);
const before = m.getSnapshot();
m.send({ type: 'UPDATE', value: 10 });
const after = m.getSnapshot();
expect(before.context.value).toBe(0);
expect(after.context.value).toBe(10);Disposal
Always dispose machines to clean up signals, abort in-flight invokes, and clear after-timers.
const m = machine(config);
m.dispose(); // aborts invokes, clears timers, disposes reactive signals
// With the explicit resource management proposal (ES2024+):
{
using m = machine(config);
m.send({ type: 'GO' });
} // dispose() called automatically via [Symbol.dispose]WARNING
Disposal does not clear persisted state. The machine may resume from the last snapshot on recreation.
Common Patterns
Traffic Light
import { machine } from '@vielzeug/clockwork';
type Event = { type: 'EMERGENCY' } | { type: 'NEXT' };
const trafficLight = machine({
initial: 'red',
states: {
green: { on: { EMERGENCY: { target: 'red' }, NEXT: { target: 'yellow' } } },
red: { on: { EMERGENCY: { target: 'red' }, NEXT: { target: 'green' } } },
yellow: { on: { EMERGENCY: { target: 'red' }, NEXT: { target: 'red' } } },
},
});Auto-dismiss notification
import { machine } from '@vielzeug/clockwork';
type Event = { type: 'DISMISS' } | { type: 'SHOW'; message: string };
const notification = machine({
context: { message: '' },
initial: 'hidden',
states: {
hidden: {
on: {
SHOW: {
actions: [
({ context, event }) => {
context.message = event.message;
},
],
target: 'visible',
},
},
},
visible: {
after: [{ delay: 5000, target: 'hidden' }],
on: { DISMISS: { target: 'hidden' } },
},
},
});Auth flow with async login
import { machine } from '@vielzeug/clockwork';
type Context = { attempts: number; user?: { id: string; token: string } };
type Event =
| { email: string; password: string; type: 'SUBMIT' }
| { type: 'LOGOUT' }
| { type: 'AUTH_SUCCESS'; user: { id: string; token: string } }
| { type: 'AUTH_FAILED' };
const auth = machine({
context: { attempts: 0 },
initial: 'unauthenticated',
states: {
authenticated: {
on: {
LOGOUT: {
actions: [
({ context }) => {
context.user = undefined;
},
],
target: 'unauthenticated',
},
},
},
error: {},
loading: {
invoke: [
{
src: async ({ entryEvent, signal }) => {
if (entryEvent.type !== 'SUBMIT') throw new Error('unexpected');
return fetch('/auth/login', {
body: JSON.stringify({ email: entryEvent.email, password: entryEvent.password }),
method: 'POST',
signal,
}).then((r) => r.json());
},
onDone: (user, _ctx) => ({ type: 'AUTH_SUCCESS', user: user as { id: string; token: string } }),
onError: (_err, _ctx) => ({ type: 'AUTH_FAILED' }),
},
],
on: {
AUTH_FAILED: { target: 'unauthenticated' },
AUTH_SUCCESS: {
actions: [
({ context, event }) => {
context.user = event.user;
},
],
target: 'authenticated',
},
},
},
unauthenticated: {
on: {
SUBMIT: {
actions: [
({ context }) => {
context.attempts += 1;
},
],
guard: ({ context }) => context.attempts < 3,
target: 'loading',
},
},
},
},
});Framework Integration
import { useEffect, useRef, useState } from 'react';
import { machine } from '@vielzeug/clockwork';
import { trafficConfig } from './machine';
function TrafficLight() {
const machineRef = useRef<ReturnType<typeof machine> | null>(null);
const [state, setState] = useState('red');
useEffect(() => {
const m = machine(trafficConfig);
machineRef.current = m;
const unsub = m.subscribe(({ state }) => setState(state));
return () => {
unsub();
m.dispose();
};
}, []);
return (
<div>
<p>Current: {state}</p>
<button onClick={() => machineRef.current?.send({ type: 'NEXT' })}>Next</button>
</div>
);
}<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { machine, type MachineInstance } from '@vielzeug/clockwork';
import { trafficConfig } from './machine';
const state = ref('red');
let m: MachineInstance<string, Record<string, never>, { type: string }>;
let unsub: (() => void) | undefined;
onMounted(() => {
m = machine(trafficConfig);
unsub = m.subscribe(({ state: s }) => { state.value = s; });
});
onUnmounted(() => { unsub?.(); m?.dispose(); });
</script>
<template>
<div>
<p>Current: {{ state }}</p>
<button @click="m?.send({ type: 'NEXT' })">Next</button>
</div>
</template>Working with Other Vielzeug Libraries
With @vielzeug/ripple
state and context are ReadonlySignal values from @vielzeug/ripple. Use effect() to drive reactive UI from Clockwork state:
import { effect } from '@vielzeug/ripple';
import { machine } from '@vielzeug/clockwork';
const m = machine(playerConfig);
effect(() => {
document.getElementById('play-btn')!.textContent = m.state.value === 'playing' ? 'Pause' : 'Play';
});
m.send({ type: 'PLAY' }); // effect runs immediatelyWith @vielzeug/herald
Bridge Clockwork transitions to a shared event bus for cross-machine coordination:
import { createBus } from '@vielzeug/herald';
import { machine } from '@vielzeug/clockwork';
const bus = createBus<{ 'auth:login': { userId: string }; 'auth:logout': void }>();
const m = machine(authConfig, {
debug: {
onTransition: ({ to }) => {
if (to === 'anonymous') bus.emit('auth:logout', undefined);
},
},
});Best Practices
- Use discriminated event unions. TypeScript infers payload types per transition from the event type string.
- Keep guards pure. Guards must not produce side effects. All mutation belongs in
actions. - Mutate context directly in actions. Actions receive a cloned draft — mutate it in place.
- Prefer shorthand transition syntax (
on: { GO: { target: 'active' } }) for single transitions. Use arrays only when you need multiple guarded alternatives. - Dispose machines when done. Always call
m.dispose()(orusing m = machine(...)) to prevent memory leaks and abort dangling invokes. - Test with
resolveTransition(). Unit-test guard logic in isolation without spinning up a full machine instance. - Keep machines focused. A machine with more than 10–15 states is usually a sign it should be split.
- Use after for timeouts. Prefer
afterover manual setTimeout in entry hooks — timers are automatically cleaned up on state exit.