# Adding an Elm-style store — a step-by-step guide This walks through building a small, Elm-like store for a page's state, using nothing but `signal`/`computed` from `@angular/core`. No new dependency, no class hierarchy, no FP background required — every term below is explained in plain words the first time it's used. If you haven't read the main [README](../README.md) yet, read it first — this doc assumes you know what `RemoteData` and `resource()` already do in this template, because the store is **not** a replacement for either of those. ## 1. When you actually need this Don't reach for this until you actually feel the pain. A single `signal` is simpler and is exactly what this template already uses for the `users/` page's selection state: ```ts protected selectedUserId = signal(null); ``` That's fine as long as there's one thing changing. The store earns its keep once a page's state has **3 or more fields that change together**, or needs an **undo / multi-step flow** (a wizard, a form with a "back" step, anything with history). ```mermaid flowchart LR q1{"Does one signal\nstill describe the state?"} q1 -->|yes| plain["Keep the plain signal(s).\nNothing to build."] q1 -->|no, 3+ fields\nchange together, or\nneeds undo/steps| store["Build a store\n(this guide)"] ``` ## 2. The core idea, with no Angular involved yet Three plain ingredients, no framework required: - **State** — a plain object describing what's true right now. Example: `{ count: 0 }`. - **A message** — a plain object describing _what happened_. Example: `{ type: 'increment' }`. (Some call this an "action" — same thing.) - **A pure update function** — a function that takes the current state and a message, and returns the **new** state. "Pure" just means: same inputs always give the same output, and it never reaches outside itself (no HTTP calls, no mutating its inputs). ```mermaid flowchart LR S["State\n{ count: 0 }"] --> R M["Message\n{ type: 'increment' }"] --> R R["reduce(state, msg)"] --> S2["New state\n{ count: 1 }"] ``` In plain TypeScript, before any Angular signal is involved: ```ts type CounterState = { count: number }; type CounterMsg = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }; function reduce(state: CounterState, msg: CounterMsg): CounterState { switch (msg.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: 0 }; } } reduce({ count: 0 }, { type: 'increment' }); // { count: 1 } — a plain function call ``` Nothing above needs Angular. That's the whole trick: the "store" is just this function plus somewhere to remember the current state. ## 3. Wiring it to a signal "Somewhere to remember the current state" is exactly what a `signal` already is. This is the whole store — one small factory function, no class: `shared/application/store.ts`: ```ts import { signal } from '@angular/core'; export function createStore( initialState: State, reduce: (state: State, msg: Msg) => State, ) { const state = signal(initialState); return { state: state.asReadonly(), dispatch: (msg: Msg) => state.update((current) => reduce(current, msg)), }; } ``` ```mermaid flowchart LR Comp["component calls\ndispatch(msg)"] --> D["dispatch()"] D --> R["reduce(state(), msg)"] R --> Set["state.set(newState)"] Set --> Sig["signal notifies\nany template reading state()"] ``` That's ~10 lines, zero new dependencies. `state` is exposed read-only (`asReadonly()`) so only `dispatch` can ever change it — nothing outside the store mutates state directly, which is what keeps `reduce` trustworthy. ## 4. A `*.machine.ts` per feature Each feature gets its own file with just its `Model` (state shape), `Msg` (union of things that can happen), and `reduce` — no Angular imports needed here either, so it's trivially unit-testable. Example: extending the `users/` feature so selecting a user is remembered as part of a richer flow (say, tracking recently viewed users too). `users/application/users.machine.ts`: ```ts export type UsersModel = { selectedUserId: number | null; recentlyViewed: number[]; }; export type UsersMsg = { type: 'select'; id: number } | { type: 'closeDetail' }; export const initialUsersModel: UsersModel = { selectedUserId: null, recentlyViewed: [], }; export function reduceUsers(model: UsersModel, msg: UsersMsg): UsersModel { switch (msg.type) { case 'select': return { selectedUserId: msg.id, recentlyViewed: [msg.id, ...model.recentlyViewed.filter((id) => id !== msg.id)], }; case 'closeDetail': return { ...model, selectedUserId: null }; } } ``` This is the point where a single `signal` stopped being enough: `recentlyViewed` is a second field that changes _together_ with `selectedUserId`, and `reduce` is the one place that keeps them in sync. ## 5. Using it from a component ```ts import { Component } from '@angular/core'; import { createStore } from '@shared/application/store'; import { initialUsersModel, reduceUsers } from '@users/application/users.machine'; @Component({ selector: 'app-users-page', template: ` @if (store.state().selectedUserId; as id) { } @else { } `, }) export class UsersPage { protected store = createStore(initialUsersModel, reduceUsers); } ``` ```mermaid sequenceDiagram participant Tpl as Template participant Store as store participant Reduce as reduceUsers() Tpl->>Store: dispatch({ type: 'select', id: 3 }) Store->>Reduce: reduce(currentModel, msg) Reduce-->>Store: newModel Store->>Store: state.set(newModel) Store-->>Tpl: state() signal updates, view re-renders ``` Compare this to the `signal` version in the current README: same event, same template swap — the difference only shows up once there's a second field to keep consistent. ## 6. Async actions — the store doesn't replace `resource()` Keep these concerns separate: `resource()` still owns _fetching_; the store only owns synchronous state that's derived from, or reacts to, what's fetched. Don't move `fetch` calls into `reduce` — `reduce` must stay pure (no side effects), so a network call has no business being inside one. ```mermaid flowchart TD subgraph async["Async data (unchanged)"] Fetch["fetchUsers()"] --> Res["resource()"] --> RD["fromResource()"] end subgraph sync["Synchronous UI state (the store)"] Msg["dispatch(msg)"] --> Reduce["reduce()"] --> State["store.state()"] end RD -.->|"read via computed()\nif the store needs to react"| State ``` If the store genuinely needs to know about a resource's status, read it in a `computed()` outside the store rather than smuggling it into `dispatch`: ```ts protected usersResource = usersResource(); protected hasUsers = computed(() => this.usersResource.hasValue()); ``` ## 7. Testing — this is the payoff `reduce` is a pure function, so testing it needs no Angular, no `TestBed`, no mocks — just call it and check the result, the same black-box style already used for `remote-data.spec.ts`: ```ts import { describe, expect, it } from 'vitest'; import { initialUsersModel, reduceUsers } from './users.machine'; describe('reduceUsers', () => { it('sets selectedUserId and pushes it onto recentlyViewed on select', () => { const next = reduceUsers(initialUsersModel, { type: 'select', id: 3 }); expect(next).toEqual({ selectedUserId: 3, recentlyViewed: [3] }); }); it('clears selectedUserId on closeDetail, keeps recentlyViewed', () => { const withSelection = { selectedUserId: 3, recentlyViewed: [3] }; const next = reduceUsers(withSelection, { type: 'closeDetail' }); expect(next).toEqual({ selectedUserId: null, recentlyViewed: [3] }); }); }); ``` No component, no DOM, no async — just data in, data out. ## Recap: the full cycle ```mermaid flowchart LR View["View\n(template event)"] -->|"dispatch(Msg)"| Dispatch["store.dispatch"] Dispatch --> Reduce["reduce(state, Msg)"] Reduce -->|"new state"| Signal["state signal .set()"] Signal -->|"state() read in template"| View ``` That loop — **view sends a message → `reduce` computes the next state → the signal updates → the view re-renders** — is the entire pattern. Everything above it (multiple `*.machine.ts` files, richer models, selectors) is the same loop repeated, not a new concept.