Multi-Machine Coordination with Events
Problem
Complex features often require multiple interacting state machines:
- A user machine (login, logout, profile updates)
- A session machine (active, idle, expired)
- A notification machine (checking, displaying, clearing)
Hard-coding dependencies between machines creates tight coupling and race conditions. Changes to one machine break assumptions in another.
Solution
Use Herald as a message bus to decouple machines. Each machine publishes events about state changes, and other machines subscribe and react accordingly. This creates a pub/sub pattern where machines are independent but coordinated.
import { machine } from '@vielzeug/clockwork';
import { createEventBus } from '@vielzeug/herald';
// Shared event types
type UserEvent = { type: 'LOGIN'; userId: string; token: string } | { type: 'LOGOUT' } | { type: 'SESSION_EXPIRED' };
type SessionEvent = { type: 'ACTIVITY' } | { type: 'INACTIVITY_WARNING' } | { type: 'EXPIRE' };
type NotificationEvent =
| { type: 'SHOW'; message: string; level: 'info' | 'warning' | 'error' }
| { type: 'AUTO_DISMISS' }
| { type: 'DISMISS' };
// Create central event bus
const events = createEventBus<
| { scope: 'user'; event: UserEvent }
| { scope: 'session'; event: SessionEvent }
| { scope: 'notification'; event: NotificationEvent }
>();
// User machine
const userMachine = machine({
initial: 'logged_out',
context: {
userId: '',
token: '',
profile: {} as Record<string, unknown>,
},
states: {
logged_out: {
on: {
LOGIN: [
{
target: 'logged_in',
actions: [recordLogin, publishLoginEvent],
},
],
},
},
logged_in: {
on: {
LOGOUT: [
{
target: 'logged_out',
actions: [clearUser, publishLogoutEvent],
},
],
SESSION_EXPIRED: [
{
target: 'logged_out',
actions: [clearUser, publishSessionExpiredEvent],
},
],
},
},
},
});
// Session machine
const sessionMachine = machine({
initial: 'idle',
context: {
inactiveSeconds: 0,
warningShown: false,
},
states: {
idle: {
on: {
USER_LOGIN: [{ target: 'active', actions: [resetInactivity] }],
USER_LOGOUT: [{ target: 'idle' }],
},
},
active: {
entry: [startActivityTimer],
exit: [stopActivityTimer],
on: {
ACTIVITY: [{ actions: [resetInactivity] }],
INACTIVITY_WARNING: [
{
target: 'warning',
actions: [markWarningShown, publishWarningEvent],
},
],
USER_LOGOUT: [{ target: 'idle', actions: [stopActivityTimer] }],
},
},
warning: {
entry: [showInactivityWarning],
on: {
ACTIVITY: [{ target: 'active', actions: [resetInactivity] }],
EXPIRE: [
{
target: 'expired',
actions: [publishSessionExpireEvent],
},
],
USER_LOGOUT: [{ target: 'idle' }],
},
},
expired: {
entry: [expireSession],
type: 'final',
},
},
});
// Notification machine
const notificationMachine = machine({
initial: 'idle',
context: {
message: '',
level: 'info' as const,
autoDismissMs: 0,
},
states: {
idle: {
on: {
SHOW_NOTIFICATION: [
{
target: 'visible',
actions: [recordNotification, scheduleAutoDismiss],
},
],
},
},
visible: {
on: {
DISMISS: [{ target: 'idle', actions: [clearNotification] }],
AUTO_DISMISS: [{ target: 'idle', actions: [clearNotification] }],
SHOW_NOTIFICATION: [
{
target: 'visible',
actions: [recordNotification, scheduleAutoDismiss],
},
],
},
},
},
});
// Action functions — mutate context directly
const recordLogin = ({ context, event }: any) => {
context.userId = event.userId;
context.token = event.token;
};
const publishLoginEvent = () => {
events.emit({ scope: 'user', event: { type: 'LOGIN', userId: '', token: '' } });
};
const publishLogoutEvent = () => {
events.emit({ scope: 'user', event: { type: 'LOGOUT' } });
};
const publishSessionExpiredEvent = () => {
events.emit({ scope: 'user', event: { type: 'SESSION_EXPIRED' } });
};
const publishWarningEvent = () => {
events.emit({
scope: 'notification',
event: { type: 'SHOW', message: 'Session expiring soon', level: 'warning' },
});
};
const publishSessionExpireEvent = () => {
events.emit({
scope: 'notification',
event: {
type: 'SHOW',
message: 'Your session has expired. Please log in again.',
level: 'error',
},
});
};
const resetInactivity = ({ context }: any) => {
context.inactiveSeconds = 0;
context.warningShown = false;
};
const markWarningShown = ({ context }: any) => {
context.warningShown = true;
};
const clearUser = ({ context }: any) => {
context.userId = '';
context.token = '';
context.profile = {};
};
const recordNotification = ({ context, event }: any) => {
context.message = event.message;
context.level = event.level;
};
const clearNotification = ({ context }: any) => {
context.message = '';
context.level = 'info';
};
const startActivityTimer = () => {
// Start 15-min idle timer
};
const stopActivityTimer = () => {
// Cancel timer
};
const showInactivityWarning = () => {
// Show warning UI
};
const expireSession = () => {
// Clean up session
};
const scheduleAutoDismiss = ({ context }: any) => {
// Auto-dismiss after specified duration
};
// Initialize machines
const user = userMachine;
const session = sessionMachine;
const notification = notificationMachine;
// Connect event bus to machines
events.on(({ scope, event }) => {
if (scope === 'user') {
if (event.type === 'LOGIN') {
session.send({ type: 'USER_LOGIN' } as any);
} else if (event.type === 'LOGOUT') {
session.send({ type: 'USER_LOGOUT' } as any);
}
}
if (scope === 'notification') {
if (event.type === 'SHOW') {
notification.send({
type: 'SHOW_NOTIFICATION',
message: event.message,
level: event.level,
} as any);
}
}
});
// Track user activity
document.addEventListener('mousemove', () => {
session.send({ type: 'ACTIVITY' } as any);
});
// Idle detection (every 10 min with no activity)
setInterval(() => {
const ctx = session.context.value;
if (ctx.inactiveSeconds > 900) {
session.send({ type: 'INACTIVITY_WARNING' } as any);
} else {
session.send({ type: 'ACTIVITY' } as any);
}
}, 60000);
export const userState = user.state;
export const sessionState = session.state;
export const notificationMessage = notification.context.pipe((c) => c.message);
export const notificationLevel = notification.context.pipe((c) => c.level);
export function login(userId: string, token: string) {
user.send({ type: 'LOGIN', userId, token } as any);
}
export function logout() {
user.send({ type: 'LOGOUT' } as any);
}Pitfalls
Circular event loops - If machine A publishes event that notifies B, and B publishes back to A, infinite loops occur. Always use directed dependencies: User → Session → Notification, never cycle back.
Lost events during machine initialization - Events emitted during setup before a machine is ready get dropped. Create machines synchronously before subscribing, or buffer events until all machines are initialized.
Race conditions on simultaneous events - If both user and session emit events that affect notification simultaneously, notification machine may not handle both. Use a queue in the event bus, or ensure events are idempotent.
Tight timing assumptions - One machine assumes another has completed before reacting. If async operations are involved, use explicit completion events instead of state checks (check for 'expired' state vs listen for 'SESSION_EXPIRED' event).
Hard to debug multi-machine state - When machines coordinate, debugging requires tracking all states simultaneously. Enable debug hooks on each machine and log to a central registry:
debug: { onTransition: (info) => log({ clockwork: 'session', ...info }) }.