A progressive teaching guide (docs/fp-tea-atomic-design.md): FP fundamentals, The Elm Architecture, and atomic design, taught Elm-then-this-app with the real store/machine/value-object code, plus four recipes and a glossary. It owns the teaching arc and cross-references ARCHITECTURE.md/ADR-0001 rather than duplicating them. Documents reality where the PRD diverged (no state-debug-view feature; per- wizard stores; reduce vs update naming) and flags the absent debug view as an open question. Adds pointer links from ARCHITECTURE.md and CLAUDE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
27 KiB
Functional programming, The Elm Architecture & atomic design — a guide
A progressive learning guide for developers who are strong programmers but have done little or no functional programming (FP) in the frontend. It teaches three ideas and shows they are one idea:
- FP for the frontend — pure functions, immutability, types that can't lie.
- The Elm Architecture (TEA) — one state, one direction, one pure update.
- Atomic design — small pure components composed into bigger ones.
The claim of Part 5 is that TEA and atomic design are the same principle at two scales, and this app already lives that way.
How to read it. A junior can read top-to-bottom and arrive at "I can add a feature." A senior can skim Parts 1–4 and jump to Part 5 (FP × atomic design), Part 6 (why it reduces complexity), and Part 7 (the recipes). Every term is defined in plain words on first use and again in the glossary (Part 8).
This guide is the teaching layer. For the reference deep-dives it points to
ARCHITECTURE.md and
ADR-0001 rather than repeating them.
Every code snippet below is real code from this repo, with its file path.
Part 1 — Why this exists (the complexity problem)
Most UI bugs are not algorithmic. They come from state that can lie:
- Two booleans that disagree —
isLoadingistrueanddatais set. - A screen that shows an error and a success at the same time.
- A "Submit" that fires while a field is still invalid.
- A wizard whose "next step" field drifts out of sync with the answers.
- The 3am question: "who changed this value, and when?"
The root cause is the same each time: state is scattered across many mutable variables, and it changes from many places. The number of states explodes, and most of them are nonsense the compiler still lets you write.
The promise of this architecture, in one line:
One state. One direction. Pure logic. Predictable everything.
Keep all the state in a single value; change it only by sending a message to one pure function; let the view be a function of that state; push side effects (HTTP, timers) to the edges. The illegal states stop being reachable, the update logic becomes a unit test with no mocks, and onboarding becomes "learn one small pattern, apply it everywhere." The rest of this guide builds that up from first principles.
Part 2 — FP fundamentals for the frontend
FP here is not category theory. It is four habits that make state predictable. Each is shown in Elm (a tiny, canonical functional UI language — our teaching device) and then in this app's real TypeScript.
2a. Pure functions
A pure function's output depends only on its inputs, and it changes nothing else — no network, no writing to outside variables, no clock. Same input → same output, every time. That is what makes it trivially testable (no mocks) and easy to reason about.
-- Elm: pure by default — there is no way to do I/O inside this
add : Int -> Int -> Int
add a b = a + b
In this app, the parsers and reducers are pure. For example
(src/app/registratie/domain/value-objects/uren.ts):
export function parseUren(raw: string): Result<string, Uren> {
const t = raw.trim();
const n = Number(t);
if (t === '' || !Number.isInteger(n) || n < 0) {
return err('Vul een geheel aantal in (0 of meer).');
}
return ok(n as Uren);
}
Give it "4160", you always get the same ok(4160). No surprises. Why it helps:
its unit test is one line per case and never flakes.
2b. Immutability
Never mutate a value in place; produce a new value instead. The spread { ...s, x }
copies the old fields and overrides one.
-- Elm: { model | count = model.count + 1 } makes a NEW record
This app's reducers always return a fresh object — e.g.
src/app/herregistratie/domain/herregistratie.machine.ts:
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, draft: { ...s.draft, [key]: value } };
}
Why it helps: because old states are never overwritten, back-navigation and "resume where you left off" are free (the previous value still exists), and Angular's change detection can tell something changed by identity. Time-travel/replay is possible because nothing is destroyed.
2c. Unidirectional data flow
Data flows down; events flow up; there is exactly one loop. A view never reaches sideways to mutate another component's state — it emits an event, which becomes a message, which goes through the one update function, which produces the next state, which flows back down. Part 3 makes this loop concrete.
2d. Modelling state with types (make illegal states unrepresentable)
Two kinds of type do most of the work:
- Product type — a record that holds several things at once (
interface Draft { uren; jaren; punten }). - Sum type / discriminated union — a value that is exactly one of several
labelled shapes, where a
tagsays which, and each shape carries only the data that makes sense for it.
The decisive move is choosing types so that illegal states can't be written down.
Compare three booleans (2³ = 8 combinations, most nonsense) with one union of the 4 real
states — see ARCHITECTURE.md §2a
for the full RemoteData treatment and diagram. The wizard's own Model is the same
idea (herregistratie.machine.ts):
export type WizardState =
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial<Record<keyof Draft, string>> }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
Because step and errors exist only on Editing, and Submitting/Submitted
carry already-validated Valid data and no error field, "submitting while a field is
invalid" or "success screen with errors still set" cannot be constructed. The bug class
is gone at compile time.
FP term — sum type / discriminated union: one value that is one-of-several labelled shapes. The
tagdiscriminates; the compiler then knows which fields exist.
2e. Side effects at the edges (functional core, imperative shell)
A side effect is anything beyond computing a return value: HTTP, timers,
localStorage, focus. Pure code can't do them. So we keep a pure core (parsers,
reducers, visibleSteps) and push every effect to a thin imperative shell (the
Angular component/service). The core decides what the state is; the shell goes and
does things, then feeds the result back in as a message. Part 4d shows exactly how.
FP term — pure core / imperative shell: all decisions in pure functions; all I/O in a thin outer layer that calls them.
Part 3 — The Elm Architecture (TEA)
TEA is four pieces and one loop. In Elm:
- Model — the single source of truth (all your state, one value).
- Msg — every thing that can happen, as a union.
update : Msg -> Model -> Model— the only place state changes; pure.view : Model -> Html Msg— a pure function of the state that emits messages.
type alias Model = { count : Int }
type Msg = Increment | Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment -> { model | count = model.count + 1 }
Decrement -> { model | count = model.count - 1 }
view : Model -> Html Msg
view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, text (String.fromInt model.count)
, button [ onClick Increment ] [ text "+" ]
]
The runtime wires it into a loop:
graph LR
S["Model (state)"] --> V["view(Model)"]
V -->|"user event"| M["Msg"]
M --> U["update(Msg, Model) — PURE"]
U -->|"next Model"| S
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
class S,V,M,U l;
Effects don't break the loop. In Elm, update can return a Cmd (a description
of an effect — "go do this HTTP call"); the runtime performs it and feeds the result
back in as another Msg. Subscriptions are the same for incoming events (time,
websockets). The key property survives: update itself stays pure — it only ever
describes effects, never performs them. This app does the same with a small twist
(Part 4d): the effect lives in the component, and its outcome is dispatched as a Msg.
Part 4 — How we do TEA in Angular with signals
This app implements TEA with Angular signals. There is no extra state library. One
important shape difference from textbook Elm: state is per-wizard, not one global
Model — each flow (herregistratie, intake, registratie) has its own little
store. Cross-page state that must be shared lives in one root singleton
(BigProfileStore, see ARCHITECTURE.md §2e).
4a. The store — TEA's runtime in ~10 lines
src/app/shared/application/store.ts:
export interface Store<Model, Msg> {
/** The current state, as a read-only Angular signal. */
readonly model: Signal<Model>;
/** Send a message; the model becomes update(model, msg). */
dispatch(msg: Msg): void;
}
export function createStore<Model, Msg>(
init: Model,
update: (model: Model, msg: Msg) => Model,
): Store<Model, Msg> {
const model = signal(init);
return {
model: model.asReadonly(),
dispatch: (msg) => model.set(update(model(), msg)),
};
}
This is the Elm runtime: a signal holds the Model, and dispatch is the only way
to change it — it runs the pure update and sets the new value.
Naming note (read the code, not the textbook): the factory parameter is called
update(the Elm word), but each feature exports its reducer asreduceand passes it in:createStore(initial, reduce). "update" and "reduce" are the same role.
4b. Model + Msg + reduce
Mapping the four TEA pieces to real code, using the herregistratie wizard (the smallest
machine) as the example — src/app/herregistratie/domain/herregistratie.machine.ts:
- Model →
WizardState(the discriminated union from §2d). - Msg →
WizardMsg, every event as one union:
export type WizardMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
- update → the pure
reduce(state, msg)— no injection, no HTTP, no mutation:
export function reduce(s: WizardState, m: WizardMsg): WizardState {
switch (m.tag) {
case 'SetField':
return setField(s, m.key, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Seed':
return m.state;
default:
return assertNever(m); // compiler error if a Msg is unhandled
}
}
assertNever (src/app/shared/kernel/fp.ts) makes the switch exhaustive: add a new
Msg variant and forget to handle it, and the build fails. (intake.machine.ts and
registratie-wizard.machine.ts have larger unions, same exact shape.)
4c. view → template + computed() + dispatch
The container component (src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
creates the store and derives view values with computed():
private store = createStore<WizardState, WizardMsg>(initial, reduce);
readonly state = this.store.model; // a read-only signal of the Model
protected dispatch = this.store.dispatch; // the only way to change it
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
The template is a function of the state: it reads those computed() signals and
sends messages on events — it never mutates:
<app-text-input
[ngModel]="draft().uren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
...
/>
...
<app-button (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
That is the loop: state → template → event → dispatch(Msg) → reduce → new state → template.
4d. Effects → a command that dispatches the outcome
reduce is pure, so it can't call the network. The component holds a small command
method. It does the impure work, then dispatches a Msg describing what happened — the
result re-enters through the same pure loop:
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie(); // optimistic flag (shared store)
const r = await submitHerregistratie(s.data); // the actual I/O — a Result
if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); }
else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); }
}
The command itself (src/app/herregistratie/application/submit-herregistratie.ts)
returns a Result — success-or-error as a value, never a thrown exception:
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
await new Promise((r) => setTimeout(r, 800));
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
return ok(undefined);
}
The split, in one line: reducer = "what the new state is"; command = "go do the
thing, then say what happened." Incoming effects (an arriving HTTP value, a
server-owned config) are wired with effect() and untracked() so the dispatch
doesn't loop on its own write — see the BRP prefill and policy-threshold effects in
registratie-wizard.component.ts / intake-wizard.component.ts.
4e. Because state is one value, you can watch it
Each wizard exposes state as a read-only signal, deliberately public so the
teaching page can highlight the live state. See it on the in-app showcase
(src/app/showcase/concepts.page.ts, route /concepts): section 4 lights up the
current WizardState among Editing → Submitting → Submitted/Failed as you drive the
form, and section 5 shows the intake steps re-deriving as you type.
Discrepancy with the PRD — open question. The PRD refers to a dedicated "state debug view" / inspector. No such feature exists in the code today. What exists is the
/conceptsshowcase (live state highlight) and the?scenario=slow|loading|empty|errorinterceptor (src/app/shared/infrastructure/scenario.ts) for exercising async states. A JSON state inspector would be trivial here — single one-way state means you could renderJSON.stringify(state())in a panel and watch every transition — precisely because of everything in Part 6. Treat building one as a future task, not documented reality.
Part 5 — FP × atomic design (the unifying chapter)
The central idea of this guide:
FP and atomic design are the same principle at two scales — composition of pure pieces, with state pushed to the boundary.
5a. Atoms & molecules are view functions
A pure function maps inputs → output with no side effects. A presentational component does exactly that: it maps inputs → DOM, emits events, and has no injected services, no internal mutable state, no effects. Same inputs → same DOM. That is referential transparency at the component scale.
In this codebase the form atoms (text-input, radio-group) are thin wrappers over
the design system. They take config via input() and — because they implement
Angular's ControlValueAccessor — emit changes through [ngModel] / (ngModelChange).
The form-field molecule composes a label + projected control + error. The
address-fields organism (src/app/registratie/ui/address-fields/address-fields.component.ts)
composes three form-fields and emits with output():
export class AddressFieldsComponent {
value = input.required<AdresValue>(); // data in
errors = input<AdresErrors>({}); // data in
fieldChange = output<{ key: keyof AdresValue; value: string }>(); // events out
}
Read the code, not the slogan: "events up" has two real forms here. Design-system form atoms emit via
ControlValueAccessor/ngModel; higher composites useoutput(). Both are "data down, events up" — just different Angular mechanisms.
5b. Composition stays pure
Molecules compose atoms; organisms compose molecules — exactly like composing pure
functions, where the composite is still pure. address-fields is pure because the
form-field and text-input it's built from are pure. Each atomic level only uses the
level(s) below it (see the hierarchy diagram in
ARCHITECTURE.md §1).
5c. Pages / containers are the TEA runtime (the shell)
The boundary between "pure presentational" and "stateful container" is the
functional-core / imperative-shell line from §2e. The container (e.g.
herregistratie-wizard.component.ts) is where the Model signal lives, where
dispatch/reduce run, and where effects are wired. Everything below it is pure view.
5d. The loop, overlaid on the atomic layers
graph TD
subgraph shell["Container = TEA runtime (imperative shell)"]
ST["Model signal + dispatch + reduce + effects"]
end
subgraph pure["Pure presentational (functional core)"]
O["Organism (e.g. address-fields)"]
M["Molecules (form-field, async)"]
A["Atoms (text-input, radio-group, button)"]
end
ST -->|"state flows DOWN as inputs"| O
O --> M --> A
A -.->|"events flow UP (output / ngModelChange)"| O
O -.->|"(fieldChange, click)"| ST
ST -->|"dispatch(Msg) → reduce → new state"| ST
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
classDef c fill:#e8f5e9,stroke:#39870c,color:#1b5e20;
class ST l; class O,M,A c;
It is the identical Elm loop of Part 3 — just composed through the atomic hierarchy.
Part 6 — How this reduces complexity (concrete payoffs)
Each property maps to a tangible benefit you can point at in this repo:
-
Single source of truth. All wizard state is one
WizardStatevalue. To learn every way the screen can change, you read one function (reduce). There is no "who else mutates this?" -
Pure, exhaustive
reduce. It's a unit test with no mocks — pass a state and a msg, assert the next state (herregistratie.machine.spec.ts).assertNevermakes the compiler reject an unhandledMsg, so adding an event can't silently do nothing. -
Illegal states won't compile.
SubmittingcarriesValiddata and has noerrorsfield, so "submit with errors showing" is unwritable. A whole bug class disappears before runtime — contrast the 8-state boolean soup inARCHITECTURE.md§2a. -
Pure presentational components.
address-fieldsis tested by inputs → DOM and reused in two call-sites (the registratie wizard and the change-request form) with no hidden state to surprise you. -
Isolated effects. Every async path is a
submit-*command returning aResult, invoked from onerunIf*method. Async reasoning happens in one place, not sprinkled through the view. -
Debuggability. One value flowing one way means you can render and watch it (§4e) — reproduction is "set the Model to this," nothing more.
-
Onboarding. It's one small pattern repeated everywhere. Learn it once; the recipes below are that pattern written down for the four common tasks.
Part 7 — Recipes
Each recipe follows the existing pattern and naming, and ends with the same reminder: this is the same loop, again.
Recipe A — Add an atomic component (atom / molecule / organism)
When: you genuinely need a new building block (not a one-off; reuse must earn it — see CLAUDE.md §2).
Where: shared/ui/ if generic; a context's ui/ if domain-specific. Pick the level
by composition: composes nothing → atom; composes atoms → molecule; composes
molecules into a domain block → organism.
Steps: build it pure/presentational — input()s for data/config, output()s
for events, computed() for derived display; no inject, no state, no effects. Theme
only with design tokens (no hardcoded hex — CI checks via npm run check:tokens).
Add a co-located *.stories.ts titled Layer/Name.
// shape — see src/app/registratie/ui/address-fields/address-fields.component.ts
export class AddressFieldsComponent {
value = input.required<AdresValue>();
errors = input<AdresErrors>({});
fieldChange = output<{ key: keyof AdresValue; value: string }>();
}
Tests: Storybook stories + the a11y addon are the UI coverage (repo convention — no
component DOM tests; pure logic gets a .spec.ts, presentational components don't).
This is the same loop, again: data down via input(), events up via output().
Recipe B — Add state + a state update (Elm-style)
When: a new thing can happen to a feature's state.
Steps: extend the Model immutably; add a Msg variant; handle it in the pure
reduce returning a new model (keep the union exhaustive — assertNever guards
you); expose derived values with computed(); dispatch the Msg where the event
originates. Keep effects out of reduce.
// 1. Msg variant (herregistratie.machine.ts)
| { tag: 'Reset' }
// 2. reduce arm
case 'Reset': return initial;
// 3. dispatch from the view
(click)="dispatch({ tag: 'Reset' })"
Tests: reduce(model, msg) → expected model. Pure, no mocks
(herregistratie.machine.spec.ts).
This is the same loop, again.
Recipe C — Add a field + a validation rule
When: the form needs a new input with its own rule.
Steps: combine A and B. Add the field to the Draft; add/extend a SetField-style
Msg; handle it in reduce. Write validation as a pure function returning Result
(model it on parseUren / parsePostcode). Derive the error/validity with
computed() — don't store what you can compute. Render with the form-field molecule +
a field atom, and wire validity into the step/submit gating via a computed().
// pure rule (value-objects/) — Result<error, branded value>
export function parsePostcode(raw: string): Result<string, Postcode> {
/* ... */
}
// reduce uses it; the view shows the message via <app-form-field [error]="...">
Tests: the parser's .spec.ts (each accept/reject case) + a reduce spec for the
new field.
This is the same loop, again — the rule is just another pure function.
Recipe D — Add a wizard step
When: a flow needs another step.
Steps: compose A–C. Model the step's state in the Model; derive the visible steps
rather than storing "next" — copy visibleSteps(answers) from
src/app/herregistratie/domain/intake.machine.ts:
export function visibleSteps(a: Answers): StepId[] {
const steps: StepId[] = ['buitenland'];
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
steps.push('uren');
if (lageUren(a)) steps.push('scholing');
steps.push('punten', 'review');
return steps;
}
Build the step as a presentational component (Recipe A), composed from atoms/molecules.
Derive "can advance" from a computed() over the step's validity; back-navigation keeps
earlier answers for free (immutability). Effects (BRP/DUO/submit) go through submit-*
commands in the shell, with results dispatched as Msgs (Part 4d).
Tests: reduce specs for the step's messages; a story for the step component; a
visibleSteps/machine spec as the acceptance check (intake.machine.spec.ts).
This is the same loop, again — now nested inside the wizard.
Part 8 — Glossary
- Pure function — output depends only on inputs; no side effects. Trivial to test.
- Immutability — never change a value in place; produce a new one (
{ ...s, x }). - Side effect — anything beyond returning a value: HTTP, timers,
localStorage, focus. - Unidirectional data flow — data down (inputs), events up (outputs); one loop.
- Product type — a record holding several values at once (
interface Draft { ... }). - Sum type / discriminated (tagged) union — a value that is exactly one of several
labelled shapes; a
tagfield says which, and each shape carries only its own data. - Make illegal states unrepresentable — choose types so nonsense states can't be written.
Model— the single value holding all of a feature's state.Msg— a union of every event that can happen to the state.update/reduce— the one pure function mapping(Model, Msg) → next Model. (This codebase calls the factory parameterupdate; features export it asreduce.)dispatch— send aMsg; the store runsreduceand updates the signal.- Command — an impure function that does I/O and returns a
Result, after which the caller dispatches aMsgwith the outcome. Result<E, T>— success-or-error as a value:{ ok: true, value }or{ ok: false, error }.- Value object /
Brand— a type whose validity is guaranteed by its parser (e.g.Postcode); aBrand<string, 'Postcode'>is only mintable throughparsePostcode. signal— Angular's reactive container for a value.computed— a derived signal; recomputes automatically when its inputs change.- Presentational (pure) component — inputs in, events out,
computed()for display; no inject, no state, no effects. A view function. - Container component — holds the Model signal, runs
dispatch/reduce, wires effects. The TEA runtime / imperative shell. - Functional core / imperative shell — all decisions in pure functions; all I/O in a thin outer layer.
- TEA (The Elm Architecture) — Model / Msg / update / view + the one-directional loop.
See also: ARCHITECTURE.md (reference deep-dive on RemoteData,
the store, parse-don't-validate, and the .NET backend seam) and
ADR-0001 (the BFF-lite + decision-DTO
decision). Live demo: /concepts in the running app.