# 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: 1. **FP for the frontend** — pure functions, immutability, types that can't lie. 2. **The Elm Architecture (TEA)** — one state, one direction, one pure update. 3. **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`](./ARCHITECTURE.md) and [ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) 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 — `isLoading` is `true` _and_ `data` is 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 -- 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`): ```ts export function parseUren(raw: string): Result { 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 -- 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`: ```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 `tag` says 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](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans) for the full `RemoteData` treatment and diagram. The wizard's own Model is the same idea (`herregistratie.machine.ts`): ```ts export type WizardState = | { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial> } | { 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 `tag` discriminates; 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. ```elm 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: ```mermaid 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](./ARCHITECTURE.md#2e-optimistic-update--rollback-and-shared-state-across-pages)). ### 4a. The store — TEA's runtime in ~10 lines `src/app/shared/application/store.ts`: ```ts export interface Store { /** The current state, as a read-only Angular signal. */ readonly model: Signal; /** Send a message; the model becomes update(model, msg). */ dispatch(msg: Msg): void; } export function createStore( init: Model, update: (model: Model, msg: Msg) => Model, ): Store { 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 `set`s 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 as **`reduce`** and > 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: ```ts 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: ```ts 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()`: ```ts private store = createStore(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) : null)); protected step = computed(() => this.editing()?.step ?? 1); protected draft = computed(() => 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: ```html ... Vorige ``` 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: ```ts 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: ```ts export async function submitHerregistratie(data: Valid): Promise> { 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 `/concepts` showcase (live state highlight) and the `?scenario=slow|loading|empty|error` > interceptor (`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 > render `JSON.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-field`s and emits with `output()`: ```ts export class AddressFieldsComponent { value = input.required(); // data in errors = input({}); // 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 use `output()`. > 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](./ARCHITECTURE.md#1-the-big-picture-three-contexts-four-layers)). ### 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 ```mermaid 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 `WizardState` value. 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`). `assertNever` makes the compiler reject an unhandled `Msg`, so adding an event can't silently do nothing. - **Illegal states won't compile.** `Submitting` carries `Valid` data and has no `errors` field, so "submit with errors showing" is unwritable. A whole bug class disappears before runtime — contrast the 8-state boolean soup in [`ARCHITECTURE.md` §2a](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans). - **Pure presentational components.** `address-fields` is 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 a `Result`, invoked from one `runIf*` 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](../CLAUDE.md)). **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`. ```ts // shape — see src/app/registratie/ui/address-fields/address-fields.component.ts export class AddressFieldsComponent { value = input.required(); errors = input({}); 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`. ```ts // 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()`. ```ts // pure rule (value-objects/) — Result export function parsePostcode(raw: string): Result { /* ... */ } // reduce uses it; the view shows the message via ``` **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`: ```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 `Msg`s (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 `tag` field 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 parameter `update`; features export it as `reduce`.) - **`dispatch`** — send a `Msg`; the store runs `reduce` and updates the signal. - **Command** — an impure function that does I/O and returns a `Result`, after which the caller dispatches a `Msg` with the outcome. - **`Result`** — 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`); a `Brand` is only mintable through `parsePostcode`. - **`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`](./ARCHITECTURE.md) (reference deep-dive on RemoteData, the store, parse-don't-validate, and the .NET backend seam) and [ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) (the BFF-lite + decision-DTO decision). Live demo: `/concepts` in the running app.