Most interface bugs aren't wrong calculations, they're impossible combinations: a spinner and an error message at the same time, a “Pay” button that still works while the payment is running, a wizard that skipped a step. They come from describing a screen with independent booleans — isLoading, hasError, isPaid. Three booleans make eight combinations; the screen has maybe four legitimate ones.
A state machine turns the question around: list the states that can actually exist and, for each one, the events it accepts. Everything else becomes impossible by construction, not by discipline. On signals it takes about thirty lines and no library.
Three booleans, eight combinations
Here is the usual version. Each handler sets its own flag and forgets to clear another one — not out of carelessness, but because nothing forces you to think about all of them at once. The demo below runs exactly this logic on the left and a machine on the right.
// ✗ three independent flags = 8 combinations; the screen has 4 legitimate ones
loading = signal(false);
error = signal<string | null>(null);
data = signal<Order[] | null>(null);
load() {
this.loading.set(true); // forgot: error.set(null) — the old error is still on screen
this.api.orders().subscribe({
next: (orders) => this.data.set(orders), // forgot: loading.set(false)
error: (e) => this.error.set(e.message), // forgot: loading.set(false) — spinner AND error
});
}Three booleans
loading=false · error=false · data=false
Impossible combinations reached: 0
One machine
state = 'idle'
Disabled buttons = events this state doesn't accept.
The first step: a discriminated union
Before any machine, put the state in one value whose shape depends on its status. data exists only in success and error only in error, so “an error together with data” can't even be written. In the template, @switch narrows the type: inside @case ('error'), o.error is a string, not maybe-undefined.
// ✓ one value; `data` only exists in success, `error` only in error
type Load<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
readonly orders = signal<Load<Order[]>>({ status: 'idle' });With @default never; (Angular 21.2), adding a new status to the type and forgetting it in the template becomes a compile error. More about the new control flow in @if, @for, @switch.
A machine on signals, in thirty lines
A union says what the states are; a machine also says how you move between them. The heart is a table: for each state, the events it accepts and where each one leads. send() looks in the table; an event the current state doesn't accept is simply ignored. can() answers the same question for the template, so buttons don't copy the rules into [disabled]. Guards add conditions on data, and onEnter puts side effects in a single place.
import { signal } from '@angular/core';
export type Transitions<S extends string, E extends string> = {
readonly [state in S]: Partial<Record<E, S>>;
};
export interface MachineOptions<S extends string, E extends string> {
/** extra conditions: the event is allowed only if its guard returns true */
readonly guards?: Partial<Record<E, (from: S) => boolean>>;
/** side effects when entering a state — one place for API calls, timers, analytics */
readonly onEnter?: Partial<Record<S, () => void>>;
}
export function createMachine<S extends string, E extends string>(
initial: S,
transitions: Transitions<S, E>,
options: MachineOptions<S, E> = {},
) {
const state = signal<S>(initial);
const target = (event: E) => transitions[state()][event];
const can = (event: E) =>
target(event) !== undefined && (options.guards?.[event]?.(state()) ?? true);
return {
state: state.asReadonly(), // nobody can .set() it from outside
can, // for [disabled], instead of copying conditions into templates
send(event: E): boolean {
if (!can(event)) return false; // not allowed in this state: nothing happens
const next = target(event)!;
state.set(next);
options.onEnter?.[next]?.();
return true;
},
};
}A checkout, with a live state diagram
A payment flow is the classic case: going back is allowed from shipping but not while the payment is running, you can't leave shipping without an address, and a declined card needs a way back to the payment step. All of these are lines in a table.
Two items in the cart. NEXT goes to shipping.
- Every event and the transition it caused appear here.
import { Service, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { createMachine } from './machine';
import { PaymentsApi } from './payments-api';
type Step = 'cart' | 'shipping' | 'payment' | 'paying' | 'paid' | 'failed';
type CheckoutEvent = 'NEXT' | 'BACK' | 'PAY' | 'PAID' | 'DECLINED' | 'RETRY';
@Service()
export class CheckoutStore {
private readonly payments = inject(PaymentsApi);
readonly address = signal('');
readonly error = signal<string | null>(null);
readonly machine = createMachine<Step, CheckoutEvent>(
'cart',
{
cart: { NEXT: 'shipping' },
shipping: { NEXT: 'payment', BACK: 'cart' },
payment: { PAY: 'paying', BACK: 'shipping' },
paying: { PAID: 'paid', DECLINED: 'failed' }, // no BACK, no second PAY while paying
failed: { RETRY: 'payment' },
paid: {}, // final state
},
{
guards: {
// the guard gets the current state: no reference to `this.machine` inside its own definition
NEXT: (from) => from !== 'shipping' || this.address().trim() !== '',
},
onEnter: { paying: () => void this.pay() },
},
);
private async pay(): Promise<void> {
try {
await firstValueFrom(this.payments.charge());
this.machine.send('PAID');
} catch (e) {
this.error.set((e as Error).message);
this.machine.send('DECLINED');
}
}
}Look at the store: the template never decides which buttons are allowed, and no component calls the payment API. The bank is called in exactly one place — on entering paying — so a double click, a back button or a component that renders twice can't charge the card twice.
When to use a library: XState
A hand-written machine is enough for one flow on one screen. When states nest (a checkout inside an app that can go offline), run in parallel (upload and form at the same time), depend on timers, or you want a visual editor and generated tests, XState is the standard library for statecharts. The idea doesn't change: states, events, transitions. The connection status on the WebSockets page is a small machine too.
When YES, when NO
- Multi-step flows: checkout, onboarding, wizards, approvals.
- Anything asynchronous with a visible status: loading, error, retry, cancel.
- Controls with modes (view, edit, drag, preview) and connections that come and go — see WebSockets.
- A simple toggle: one boolean signal is perfect.
- Values derived from others: that's computed(), not a state.
- Forms with independent fields: each field is its own state; the form library already knows valid, dirty and pending.
Common pitfalls
- One boolean per piece of the screen. Every new flag doubles the combinations nobody has tested.
- A status string without a table.
status.set('paid')from anywhere is booleans again, in one variable. - Rules copied into templates.
[disabled]="step === 'paying' || !address"in three places; askcan()instead. - Side effects scattered across components. Two components react to “paying” and the card is charged twice. Use
onEnter, once. - A state with no way out. “failed” without RETRY or BACK, and the only escape is reloading the page.
Cheat sheet
symptom a spinner and an error together, a button that works when it shouldn't
cause independent booleans: n flags = 2^n combinations, most of them impossible
first step a discriminated union: { status: 'success'; data } | { status: 'error'; error }
machine one state signal + a table state → { EVENT: next state }
send(event) allowed only if the table has it (and the guard says yes); otherwise ignored
can(event) for [disabled] and for hiding actions, never duplicated conditions
guards conditions on data (an address, a valid form) next to the table
onEnter side effects in ONE place: entering 'paying' calls the API
final states {}: nothing can leave 'paid'
every state needs a way out (failed → RETRY), or users get stuck
derived values still computed(); the machine only decides what CAN happen
nested / parallel states inside states, timers, a visual editor → XState