Files
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

8.5 KiB

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 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:

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).

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).
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:

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:

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)),
  };
}
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:

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

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);
}
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 reducereduce must stay pure (no side effects), so a network call has no business being inside one.

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:

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:

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

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.