CI / frontend (push) Successful in 1m46s
CI / storybook-a11y (push) Successful in 4m23s
CI / backend (push) Successful in 1m14s
CI / codeql (csharp) (push) Has been cancelled
CI / codeql (javascript-typescript) (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / e2e (push) Has been cancelled
npm update brought every package to the latest version its existing package.json range allows (Angular tooling 22.0.2/22.0.4 -> 22.0.5, prettier 3.8.4 -> 3.9.4, typescript-eslint 8.62.0 -> 8.62.1); package.json itself needed no range changes. Auditing actual deprecation warnings (not just outdated versions) found nothing further to fix: @angular/platform-browser-dynamic and @angular-devkit/build-angular are deprecated by Angular but still required peer dependencies of the latest published @storybook/angular (10.4.6 — peer range still `>=18.0.0 < 22.0.0`, already why .npmrc sets legacy-peer-deps); jest-process-manager/expect-playwright are transitive-only through @storybook/test-runner's latest stable (0.24.4). No newer version of either Storybook package exists yet that drops them. The remaining npm audit advisory (@babel/core, low severity) is the same already-documented, deliberately-left issue in README.md (fixing it downgrades Angular). Left package.json's overrides untouched. The prettier bump alone changed formatting opinions on files this session didn't otherwise touch (a stale markdown italics marker, a few object-literal wrap points) — reformatted everything so `format:check` (part of CI) doesn't regress. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
105 lines
5.1 KiB
Plaintext
105 lines
5.1 KiB
Plaintext
import { Meta } from '@storybook/addon-docs/blocks';
|
|
|
|
<Meta title="Foundations/State Machines (TEA)" />
|
|
|
|
# State machines (The Elm Architecture, in Angular)
|
|
|
|
Every form or wizard with validation or submission in this app is wired the **same
|
|
way**: one Model, one Msg union, one pure `reduce`, one command per side effect. Pick any
|
|
one — `herregistratie.machine.ts` is the fullest worked example — and the shape
|
|
transfers everywhere else.
|
|
|
|
## Model / Msg / reduce
|
|
|
|
```ts
|
|
// Model — everything the UI needs to render, as ONE tagged union
|
|
export type WizardState = { tag: 'step1'; draft: Draft } | { tag: 'step2'; valid: Valid } | …;
|
|
|
|
// Msg — every way the Model is allowed to change
|
|
export type WizardMsg = { tag: 'FieldChanged'; field: string; value: string } | { tag: 'NextStep' } | …;
|
|
|
|
// reduce — PURE: (current, message) -> next. No I/O, no Date.now(), no randomness.
|
|
export function reduce(s: WizardState, m: WizardMsg): WizardState { … }
|
|
```
|
|
|
|
Because the whole state is one value, a bug reproduces from a message log; because
|
|
`reduce` is pure, every transition is a one-line assertion in a spec — no `TestBed`, no
|
|
mocked HTTP, just `expect(reduce(state, msg)).toEqual(next)`.
|
|
|
|
## Commands: side effects stay OUT of the reducer
|
|
|
|
`reduce` only ever answers "what is the new state" — it never calls `fetch`. A
|
|
**command** (an `application/submit-*.ts` file, or a store method) does the I/O, then
|
|
dispatches a message describing the outcome:
|
|
|
|
```ts
|
|
// command = "go do it, then say what happened" — reduce never sees the HTTP call itself
|
|
async function submit(store: Store<WizardState, WizardMsg>) {
|
|
const r = await adapter.submit(toDto(store.model()));
|
|
store.dispatch(
|
|
r.ok
|
|
? { tag: 'SubmitConfirmed', referentie: r.value }
|
|
: { tag: 'SubmitFailed', error: r.error },
|
|
);
|
|
}
|
|
```
|
|
|
|
This is also how a machine receives **server-owned config** without becoming aware of
|
|
HTTP: `intake.machine.ts`'s scholing threshold has an offline fallback
|
|
(`SCHOLING_THRESHOLD_DEFAULT`) baked into the model, and a plain `SetPolicy` message
|
|
that overwrites it once the real value arrives — the machine doesn't know or care that
|
|
the value came from a `resource()` fetch.
|
|
|
|
## `createStore`: the one wiring idiom
|
|
|
|
```ts
|
|
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
|
readonly model = this.store.model; // Signal<WizardState> — template reads this
|
|
dispatch = this.store.dispatch; // template calls this, on click/input/etc — never mutates
|
|
```
|
|
|
|
A page or component **never** hand-rolls `signal(initialModel)` plus its own local
|
|
`dispatch` function that calls `reduce` inline — that's the same idea reinvented with a
|
|
worse name, and it's the thing a newcomer copies if two idioms are visible side by side.
|
|
Wire every machine through `createStore`, full stop.
|
|
|
|
`dispatch` uses `model.update(…)`, not `model.set(reduce(model(), msg))` — the latter
|
|
reads `model()` _inside_ the call, which means an `effect()` that both reads `model` and
|
|
calls `dispatch` would subscribe to its own write and livelock. `.update()`'s callback
|
|
receives the current value directly, untracked.
|
|
|
|
## Naming
|
|
|
|
- A top-level machine's types are **context-prefixed**: `ChangeRequestState`,
|
|
`ChangeRequestMsg`, `WizardState`, `WizardMsg` — never bare `State`/`Msg`. A bare name
|
|
reads fine in the one file that defines it and then collides (or forces an import
|
|
alias) the moment two machines are open side by side.
|
|
- A top-level machine exports `initial` (the starting Model) and `reduce` — unprefixed,
|
|
since the file/module already disambiguates them at the import site
|
|
(`import { initial, reduce } from './herregistratie.machine'`).
|
|
- A **composable sub-machine** — one embedded _inside_ a parent Model, like
|
|
`upload.machine.ts`'s upload-widget state living inside the registratie wizard's own
|
|
Model — keeps **prefixed value exports** instead: `initialUpload`, `reduceUpload`.
|
|
The parent machine already imports several machines' `initial`/`reduce`; prefixing the
|
|
sub-machine's exports avoids a wall of `as` import aliases at the composition site.
|
|
|
|
## Derive, don't store
|
|
|
|
If a value can be computed from the Model, it is **not** a field on the Model. The
|
|
wizard's visible steps are `visibleSteps(answers)`, a pure function of the current
|
|
answers — not a `visibleSteps: Step[]` field someone has to remember to keep in sync
|
|
every time an answer changes. The reflex: before adding a field, ask "could this just be
|
|
a function of what I already have?"
|
|
|
|
## Where RemoteData fits in
|
|
|
|
A machine owns the **domain** lifecycle of what it holds once it exists (draft →
|
|
submitted → approved, in the brief's case). It should generally _not_ also own the
|
|
**fetch** lifecycle (loading/failed) for the initial GET that produces it — that's a
|
|
generic concern `RemoteData` already models once, consistently, across the app (see
|
|
[Foundations/RemoteData & Async](?path=/docs/foundations-remotedata-async--docs)). Where
|
|
a machine's own state happens to have `loading`/`failed` tags that purely mirror that
|
|
fetch, project them onto a `RemoteData` at the store layer for `<app-async>` to render
|
|
(`BriefStore.remoteData` is the worked example) rather than teaching every consumer to
|
|
hand-roll a `@switch` over the machine's own tags.
|