AngularAtlased. 22.2

Ch. 14Advanced reactivity64 / 75

State machines with signals

No more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.

architecture ~0 min read

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.

Why it matters
Signals make derived state easy, but they don't stop you from setting two signals that contradict each other. A machine is the missing rule: one signal holds the state, and the only way to change it is a transition you have declared.

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
  });
}
LiveThe same events: booleans vs a machine

Three booleans

loading=false · error=false · data=false

Nothing loaded yet

Impossible combinations reached: 0

One machine

state = 'idle'

Nothing loaded yet

Disabled buttons = events this state doesn't accept.

On the left, press LOAD, then FAIL: the spinner and the error appear together. On the right, the same order is impossible — the buttons the current state doesn't accept are disabled.

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.

LiveCheckout: states, events, guards
NEXT NEXT PAY PAID DECLINED BACK BACK RETRY cartshippingpaymentpayingpaidfailed

Two items in the cart. NEXT goes to shipping.

  1. Every event and the transition it caused appear here.
Go through the steps. In “shipping”, NEXT stays disabled until you type an address (a guard). During “paying”, only the bank can move the machine: try BACK. Tick “decline the card” to see the way out of “failed”.
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

When YES
  • 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.
When NO
  • 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; ask can() 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

Ch. 14 · 0 of 4 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. Signals under the hood
  2. Advanced RxJS: higher-order operators
  3. NgRx SignalStore
  4. State machines with signals
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation