$machine
$machine
$machine creates a small reactive mode machine for UI and application flows.
It is more than a regular service or factory because the returned machine is
designed to be wrapped by AngularTS scope proxies: current and data update
templates naturally, and send() batches transition work when a scope proxy is
observing the machine.
Use ordinary services for IO, persistence, and shared domain logic. Use
$machine when the important part is the allowed flow between modes.
Use $workflow when the flow also
needs command boundaries, diagnostics, snapshots, restore, retry, or repeat.
Create a Machine
Inject $machine and assign the created machine to a controller or scope
property:
app.controller('SessionCtrl', function ($machine) {
this.session = $machine({
initial: 'setup',
data: {
roomId: '',
error: '',
},
transitions: {
setup: {
join(data, message) {
data.roomId = message.roomId;
return 'waiting';
},
},
waiting: {
matched(data, message) {
data.roomId = message.roomId;
return 'playing';
},
unavailable(data, reason) {
data.error = reason;
return 'setup';
},
},
},
});
});
Docs call current a mode. Transition handlers return a mode string to move
to another mode.
Register a Named Machine
Use module.machine(name, config) when a machine should be injectable by name:
app.machine('sessionMachine', {
initial: 'setup',
data: {
roomId: '',
},
transitions: {
setup: {
join(data, message) {
data.roomId = message.roomId;
return 'waiting';
},
},
},
});
app.controller('SessionCtrl', function (sessionMachine) {
this.session = sessionMachine;
});
You can also provide a resolvable config factory so it can read injectables at registration time:
function sessionMachineConfig(appSettings) {
return {
initial: appSettings.initialMode,
data: {
roomId: '',
error: '',
},
transitions: {
setup: {
join(data, message) {
data.roomId = message.roomId;
return 'waiting';
},
},
},
};
}
sessionMachineConfig.$inject = ['appSettings'];
app.machine('sessionMachine', sessionMachineConfig);
This is useful when defaults depend on environment, user state, or persisted configuration.
Named machines are regular DI services. The injector creates one machine instance and reuses it for that injectable name.
Template API
The machine exposes a small template-friendly API:
<button ng-disabled="!$ctrl.session.matches('setup')">Random</button>
<div ng-show="$ctrl.session.matches('waiting')">Waiting for opponent...</div>
<span>{{ $ctrl.session.data.roomId }}</span>
Runtime API
session.current; // current mode string
session.data; // reactive data object
session.matches('setup'); // true when current mode is setup
session.can('join', payload); // true when current mode can run join
session.send('join', payload);
session.snapshot(); // structured clone of { current, data }
session.restore(snapshot);
send(type, payload) returns true when a transition entry exists for the
current mode, its optional guard passes, and its handler runs. Missing
transitions and blocked guarded transitions return false and do not throw.
$machine does not record diagnostics for blocked or missing transitions; use
$workflow when failures need structured evidence, retry, or recovery.
Transition return values:
- return a mode string to change
current - return
false,undefined, an empty string, a non-string value, or no value to stay in the current mode
can(type, payload) checks the transition table and any optional guard for the
current mode. It does not run the transition target. Guards should be
side-effect-free because templates may call can() repeatedly.
Guarded Transitions
Use a transition descriptor when a transition has a cheap synchronous condition
that templates or controllers should be able to ask about through can():
const session = $machine({
initial: 'setup',
data: {
roomId: '',
inviteCode: '',
},
transitions: {
setup: {
join: {
guard(data, message) {
return !!data.inviteCode && message.roomId !== '';
},
target(data, message) {
data.roomId = message.roomId;
return 'waiting';
},
},
},
},
});
session.can('join', { roomId: 'abc' }); // evaluates guard only
session.send('join', { roomId: 'abc' }); // runs target when guard passes
The original function shorthand remains valid:
join(data, message) {
data.roomId = message.roomId;
return 'waiting';
}
Guards and transitions are synchronous. Put async work, retries, diagnostics,
and command recovery in $workflow,
then use a machine transition to project the resulting mode/data into the UI.
Example: Tic Tac Toe
A machine is useful when user actions must follow a small set of legal modes.
This example keeps a tic tac toe game in playing until a move produces a win
or draw. Once the machine reaches xWon, oWon, or draw, there is no move
transition for that mode, so further moves return false.
app.controller('GameCtrl', function ($machine) {
const wins = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
const winnerOf = (board) => {
for (const [a, b, c] of wins) {
const mark = board[a];
if (mark !== '-' && mark === board[b] && mark === board[c]) {
return mark;
}
}
return '';
};
this.game = $machine({
initial: 'playing',
data: {
board: ['-', '-', '-', '-', '-', '-', '-', '-', '-'],
nextPlayer: 'X',
winner: '',
moveCount: 0,
lastError: '',
},
transitions: {
playing: {
move(data, { index }) {
if (
!Number.isInteger(index) ||
index < 0 ||
index >= data.board.length ||
data.board[index] !== '-'
) {
data.lastError = 'invalid_move';
return false;
}
const player = data.nextPlayer;
data.board[index] = player;
data.moveCount += 1;
data.lastError = '';
const winner = winnerOf(data.board);
if (winner) {
data.winner = winner;
return winner === 'X' ? 'xWon' : 'oWon';
}
if (data.moveCount === data.board.length) {
return 'draw';
}
data.nextPlayer = player === 'X' ? 'O' : 'X';
return 'playing';
},
},
},
});
});
<button
ng-repeat="cell in $ctrl.game.data.board track by $index"
ng-disabled="!$ctrl.game.matches('playing') || cell !== '-'"
ng-click="$ctrl.game.send('move', { index: $index })"
>
{{ cell }}
</button>
<p ng-show="$ctrl.game.matches('playing')">
Next: {{ $ctrl.game.data.nextPlayer }}
</p>
<p ng-show="$ctrl.game.data.winner">Winner: {{ $ctrl.game.data.winner }}</p>
The transition owns both validation and projection. A valid move mutates
data.board, updates whose turn is next, and returns the next mode. An invalid
move records lastError and returns false, leaving current unchanged.
Machine transitions do not roll back data mutations when they return false or
throw.
Persist With Transition Hooks
Transition hooks are a convenient place to persist the game after each handled
move. The hook runs after current has been updated, so the snapshot contains
the terminal mode when the move ends the game.
app.controller('GameCtrl', function ($machine) {
const storageKey = 'tic-tac-toe';
this.game = $machine({
initial: 'playing',
data: {
board: ['-', '-', '-', '-', '-', '-', '-', '-', '-'],
nextPlayer: 'X',
winner: '',
moveCount: 0,
lastError: '',
},
transitions: {
playing: {
move(data, { index }) {
// Same move logic as above.
},
},
},
hooks: {
transition({ machine }) {
localStorage.setItem(storageKey, JSON.stringify(machine.snapshot()));
},
},
});
const saved = localStorage.getItem(storageKey);
if (saved) {
this.game.restore(JSON.parse(saved));
}
});
restore() does not run hooks, so loading the saved game will not immediately
write another snapshot. The next successful send() call will persist the
updated machine state.
Snapshots
Use snapshot() to capture the durable machine state. A snapshot is a
structured clone of { current, data }:
const snapshot = session.snapshot();
localStorage.setItem('session', JSON.stringify(snapshot));
A snapshot contains only current and data. It has no version or machine id,
and it does not include transitions or hooks; those still come from the machine
config. Use $workflow or a workflow supervisor when persisted recovery needs
versioning, diagnostics, history, retry, or migration.
The clone uses the browser’s structuredClone() behavior. Values such as
objects, arrays, Dates, Maps, Sets, typed arrays, and cycles can be cloned.
Functions, DOM nodes, and other non-cloneable values will throw the native
structured clone error.
JSON persistence is a narrower contract. Use JSON.stringify(snapshot) only
when data is JSON-compatible, or add your own encode/decode layer before
storing the snapshot.
Use restore(snapshot) to recover an existing machine:
const snapshot = JSON.parse(localStorage.getItem('session'));
session.restore(snapshot);
restore() mutates the existing data object in place so scope proxies and
templates keep their identity. Keys missing from the snapshot are removed,
nested plain objects are merged in place, and non-plain values like arrays are
replaced with cloned snapshot values. Restore does not run transition hooks.
Hooks
Use hooks.enter and hooks.exit for mode-specific effects. Use
hooks.transition for logging or cross-cutting work that should run after any
handled transition, including same-mode transitions:
const session = $machine({
initial: 'setup',
data: {
status: 'idle',
},
transitions: {
setup: {
join() {
return 'waiting';
},
},
waiting: {
matched() {
return 'playing';
},
},
},
hooks: {
exit: {
setup({ data }) {
data.status = 'joining';
},
},
enter: {
waiting({ data }) {
data.status = 'waiting';
},
},
transition({ type, from, to }) {
console.log(`${type}: ${from} -> ${to}`);
},
},
});
Hook context contains type, from, to, payload, data, and machine.
exit runs before current changes, enter runs after current changes, and
transition runs after mode-specific hooks. Missing transitions do not run
hooks.
Hooks run synchronously inside the same send() batch. A hook may call
machine.send() to run another valid transition; nested sends are batched with
the outer transition. If a transition or hook throws, the error is rethrown,
prior data/current mutations are kept, and AngularTS still schedules bound
scopes for the keys touched by the attempted transition.
Scope Ownership
$machine(config) does not tie the machine to the lifetime of any one scope.
The machine stores durable mode and data internally. When an AngularTS scope
proxy wraps the machine, the machine registers with that proxy and uses its
scheduler for reactive template updates and batched send() calls.
That means a machine can be created once, assigned to a component, survive that component being destroyed, and later be assigned to another scope.
An explicit $machine($scope, config) call is also supported when a caller
wants to bind the machine to a scope immediately, but assigning the machine to a
controller or scope property is usually enough. Named machines registered with
module.machine() follow the same rule: they bind when a scope proxy observes
them.
TypeScript
import { defineMachine } from '@angular-wave/angular.ts';
type SessionData = {
roomId: string;
error: string;
};
type SessionEvents = {
join: { roomId: string };
fail: string;
reset: undefined;
};
const config = defineMachine<SessionData, SessionEvents>({
initial: 'setup',
data: {
roomId: '',
error: '',
} satisfies SessionData,
transitions: {
setup: {
join(data, message) {
data.roomId = message.roomId;
return 'waiting';
},
},
},
});
const session = $machine(config);
session.send('join', { roomId: 'abc' });
session.send('reset');
Machine definitions are strict by default in TypeScript. If no event map is
provided, send() has no valid event names and transition maps accept no event
keys. Use defineMachine<Data, Events>() for checked event names and payloads,
or opt into ng.MachineEventMap when you intentionally need dynamic event
names.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.