Files
ng-signals-template/docs/adding-a-store.md
T
ehoandClaude Sonnet 5 7dddf875a2
ci / verify (push) Successful in 47s
docs: bring README in line with strict mode, routing, and the HTTP boundary
Updates the architecture/dependency diagrams and omissions table to match
what actually shipped: RemoteData<T> (not <E, T>), @angular/router moved
from "avoided" to "used", the new shared/infrastructure/http.ts fetch
boundary, and the CI/coverage gates now in place. Removes the now-false
"CI pipeline" omission row and adds honest rows for the linter and
per-environment config this template still deliberately skips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 10:30:02 +02:00

254 lines
8.5 KiB
Markdown

# 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<number | null>(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<State, Msg>(
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<number | null>` 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) {
<app-user-detail [userId]="id" (close)="store.dispatch({ type: 'closeDetail' })" />
} @else {
<app-user-list (select)="store.dispatch({ type: 'select', id: $event })" ... />
}
`,
})
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<number | null>` 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.