Web state-management convergence plan (October 2026)¶
This is the plan for epic #3989: converge the Angular app on one state idiom, NgRx SignalStore, with Angular resources for reads and signal forms for forms. It is planning only. Nothing starts until Chris approves it and answers §9. It follows the frontend bug-fix plan; see §8 for how the two interact.
1. Where we are (measured on main, 3 Oct 2026)¶
| Idiom | Non-spec files | Notes |
|---|---|---|
Global NgRx store (Store, select) |
159 | 48 reducer files, 79 selector files (about 450 selectors), 9 effect files (about 109 effects), and a custom request-state layer (createRequestObject) |
| normalizr | 52 | Hand-rolled {byId, allIds} entity slices; no @ngrx/entity |
signalStore |
19 | Includes the 3,000-line AF2 store |
ComponentStore |
21 | 10 hold no state; they use .effect() only to manage subscriptions |
@rx-angular/state |
5 | The v1 annotation form, which retires once AF2 replaces it |
BehaviorSubject as state |
17 | |
| ngrx-forms | 9 | |
UntypedForm* |
40 | |
Signal forms (@angular/forms/signals) |
9 | AF2 |
| Angular resources | 1 |
Versions:
| Package | Installed | Latest | Note |
|---|---|---|---|
@angular/* |
22.1 | 22.2.1 | |
@ngrx/* |
21.1.1 | 22.0.1 | Runs today only because of pnpm peer-dependency overrides |
@angular-architects/ngrx-toolkit |
21.0.1 | 22.0.0 | |
| TypeScript | ~6.0 | 7.0.2 | Stay on 6.x until Angular supports 7 |
Stability of the target APIs, read from the published typings and docs:
| API | Status |
|---|---|
resource, rxResource, httpResource (Angular 22.2) |
Stable, @publicApi 22.0 |
Signal forms (form, schema, validators, directives) |
Stable, @publicApi 22.0; only the WebMCP option is experimental |
@ngrx/signals core, /entities, /events, /rxjs-interop, /testing (22.0.1) |
No experimental markers |
@ngrx/signals/resource (new in 22: withValueOnLoading, withPreviousValueOnError and similar) |
@experimental |
Router resources (Angular 22.2): Route.resources: (ctx) => Record<string, Resource> receives params, queryParams and fragment as signals; the resources are exposed on ActivatedRoute.resources; enabled with withRouterResources() |
@developerPreview 22.2 |
@angular-architects/ngrx-toolkit 22 (withDevtools, withResource, withEntityResources, withMutations, withCallState, withDataService, withUndoRedo, withStorageSync and others) |
Community library (Angular Architects), not part of NgRx |
2. Target architecture¶
| Concern | Target | Replaces |
|---|---|---|
| Shared domain data (projects, stages, questions, memberships, studies, investigators, jobs) | Root-provided SignalStores, each owning its collections via withEntities (named collections); relations through computed/withComputed |
Global store entity slices, normalizr, cross-slice selectors |
| Feature or page state | Feature-scoped signalStore, or plain signal/computed for trivial local state |
ComponentStore, BehaviorSubject services, @rx-angular/state |
| Reads from the API | httpResource / rxResource inside a store, or a withResource-style feature; status, error and value as signals |
Load effects, loading flags, the request-state layer |
| Writes | Store methods (rxMethod or async) with explicit concurrency (exhaustMap for create/submit, concatMap for ordered updates), returning an outcome |
Write effects with ad-hoc operators |
| Cross-store and cross-feature flows (SignalR pushes, "job finished" notifications, project deleted) | NgRx Events plugin: eventGroup, withReducer, withEventHandlers, scoped events |
Global actions plus effects |
| Request status and errors | One withRequestStatus() feature and one toApiError() normaliser |
Three request-tracking schemes, three RequestStatus types, err.error reads |
| Current project and stage, and page-level reads | Router resources on the project and stage routes (resources: ({params}) => ({ project: httpResource(...) })): the param signal drives the load, and a superseded load is cancelled. Fallback: a param-keyed httpResource inside a route-provided store |
Stored loadedProjectId, LoadProjectRequest, guard wait logic, per-project loading state |
| Forms | Signal forms | ngrx-forms, UntypedForm* |
| Debugging | withDevtools (ngrx-toolkit), development builds only |
Store devtools |
Rules of thumb that will be written into an ADR and lint rules:
1. A collection that two or more features use lives in a root store with withEntities. Everything else lives in the feature.
2. Use events only when the producer must not know the consumer (SignalR, cross-feature). Otherwise call store methods directly.
3. Resources are for reads only. Mutations never go through a resource, because a resource cancels in-flight loads when its inputs change.
4. Use @ngrx/signals/resource (experimental) and community toolkit features where they remove real boilerplate. Each use is wrapped in a local signalStoreFeature, so swapping it out later touches one file (see D2).
3. Approach¶
- Strangler, area by area. Both stacks run side by side throughout; a SignalStore can
inject(Store)and read legacy selectors as signals during the transition. - One source of truth per collection. When a collection moves to a SignalStore, its reducer and selectors are deleted in the same PR and consumers are repointed. Nothing is dual-written.
- Behaviour parity. Existing specs are translated one-for-one, and changed behaviour is a deliberate, separate change.
- "Modernise as you touch it" continues for any file a feature PR edits for other reasons.
- Ratchet. From R0 onward, a lint rule plus a committed inventory script stop the old idioms from growing. The plan finishes when their counts reach zero.
4. Common acceptance criteria (every PR)¶
| # | Criterion | Verification |
|---|---|---|
| C1 | Behaviour parity. Existing specs for the migrated code pass after translation, and no user-visible change unless the PR body declares one | Spec diff review; focused pnpm exec ng test --include=… plus the three repo-wide guard specs |
| C2 | The old idiom's count for the area falls, and no other area's count rises | scripts/state-idiom-inventory output (before and after) in the PR body |
| C3 | No new global-store, ComponentStore, rx-angular, BehaviorSubject-as-state, normalizr or ngrx-forms code outside the ratchet baseline | ESLint restricted-import rule (CI, after bug-plan PR-2) |
| C4 | No new strict-TypeScript errors in touched files | tsc count for touched files, before and after |
| C5 | Zoneless-safe: signals drive templates, and no NgZone or manual detectChanges is added |
Zoneless guard spec; review |
| C6 | Docs updated: the architecture page, plus src/services/web/CLAUDE.md once patterns change |
Review |
| C7 | Reviews settled on the head and CI green. For UI areas, Chris accepts the PR preview before merge | Settled-gate output; preview sign-off |
5. Releases¶
Numbers marked PROPOSAL need confirmation.
R0: upgrade and foundations (prerequisite; four PRs)¶
R0.1 Upgrade NgRx to 22, ngrx-toolkit to 22 and Angular to 22.2 (effort M)
- Run ng update @ngrx/store@22 @ngrx/signals@22 (migration schematics), then ng update @angular/core@22.2 @angular/cli@22.2; enable withRouterResources() (developer preview, inert until a route declares resources).
- Remove the Angular-22 peer overrides from pnpm-workspace.yaml.
| # | Acceptance criterion | Verification |
|---|---|---|
| 0.1.1 | pnpm install → no peer-dependency warnings for @ngrx/* or the toolkit, and no Angular peer overrides remain |
Install log in the PR |
| 0.1.2 | Full web suite → passes, with the same tests and no new skips | CI Test Web (Angular) |
| 0.1.3 | Production build → succeeds, and the bundle is no more than 2% larger (PROPOSAL) | ng build stats before and after |
| 0.1.4 | Key journeys (smoke E2E) → pass | Targeted E2E run |
R0.2 Foundations library in core/state-kit/ (effort M)
- withRequestStatus().
- toApiError(), shared with bug-plan PR-7: whichever lands first owns it.
- withEntityCollections helpers over withEntities.
- A route-signal helper.
- Store test utilities over @ngrx/signals/testing.
- withDevtools wiring for development builds only.
- RealtimeStore with the declarative group registry (§5b).
| # | Acceptance criterion | Verification |
|---|---|---|
| 0.2.1 | Each foundation feature has unit specs for its success, error and cancellation paths | Specs |
| 0.2.2 | A reference store built from the kit (a small one-collection example used in tests) demonstrates resource reads, a write method with exhaust semantics, an event handler and devtools in development | Spec plus Storybook-free demo spec |
| 0.2.3 | Production builds don't include withDevtools |
Spec over providers; bundle check |
R0.3 ADR and conventions (effort S)
- docs/decisions/ADR-0xx-web-state-management.md records §2 and §3.
- src/services/web/CLAUDE.md gets a "State" section with the rules of thumb.
- An architecture page shows the target structure.
| # | Acceptance criterion | Verification |
|---|---|---|
| 0.3.1 | The ADR is approved by Chris and merged | Governance |
| 0.3.2 | src/services/web/CLAUDE.md names the single idiom and links the ADR |
Docs review |
R0.4 Ratchet (effort S)
- scripts/state-idiom-inventory counts each idiom by area.
- ESLint no-restricted-imports covers @ngrx/store writes, @ngrx/effects, @ngrx/component-store, @rx-angular/state, normalizr and ngrx-forms, with a suppression baseline for existing files.
| # | Acceptance criterion | Verification |
|---|---|---|
| 0.4.1 | A PR that adds a new old-idiom import in a non-baselined file → lint fails | Throwaway red commit in the PR |
| 0.4.2 | The inventory script output matches §1 on main |
Script output in the PR |
R1: low-risk retirements (parallel PRs)¶
R1.1 Remove the 10 state-less ComponentStores; they become DestroyRef/rxMethod (effort S–M).
R1.2 Replace the 17 BehaviorSubject-as-state services with signals; genuine event streams stay as RxJS (effort M).
| # | Acceptance criterion | Verification |
|---|---|---|
| 1.1 | Inventory → ComponentStore count −10, BehaviorSubject-as-state count reaches 0 | Script output |
| 1.2 | Each touched service or component keeps its public API, or its call sites are updated in the same PR; specs pass | Specs |
| 1.3 | No subscription survives component destruction in touched files | Specs (teardown assertions) |
@rx-angular/state (5 files) is not migrated. It goes when the v1 annotation form is deleted, which is the AF2 programme's decision; D4 records the dependency.
R2: pilot vertical slice, the project index (one PR; proves the whole pattern)¶
Scope:
- project-index: listings, favourites, search and filters, with 6 ComponentStores and 12 untyped-form uses.
- The project-summaries SignalR feed.
New pieces:
- a root ProjectSummariesStore, with withEntities and an httpResource read;
- an Events plugin handler for SignalR listing updates;
- signal forms for the filters.
| # | Acceptance criterion | Verification |
|---|---|---|
| 2.1 | The project index renders the same projects, filters and favourites as before | Component specs; E2E smoke; preview |
| 2.2 | A project created or updated by another user appears in the index without reload | E2E (two contexts) |
| 2.3 | The global-store project-listing slice, its selectors and its effects are deleted, and nothing references them | Inventory; tsc |
| 2.4 | Pilot write-up: lines removed vs added, and anything the kit needed to change, recorded in the ADR's "lessons" section | Docs |
| 2.R | Router-resources check on the project route answers three questions: (a) does navigation wait for a route resource before guards decide membership or not-found? (b) does withComponentInputBinding bind resources, or do components read ActivatedRoute.resources?© how do child routes reach a parent's resources? |
A spike spec plus a short write-up in the ADR; feeds the go/no-go gate |
| 2.5 | Load and filter latency does not regress, measured in the E2E harness: p95 within 10% of main (PROPOSAL) |
E2E timing |
Go/no-go gate after R2: Chris reviews the pilot and its write-up before R3 starts.
R3: core project domain (the largest; one PR per collection, in order)¶
The current project comes from the route (ProjectContextStore). This finishes the design that bug-plan PR-6 starts.
| PR | Collection or flow | Replaces |
|---|---|---|
| R3.1 | Project and stage router resources (or the fallback store, if 2.R found a blocking gap); current project and stage come from the route | loadedProjectId, project-ui reducer, LoadProjectRequest, project guard waits |
| R3.2 | Memberships, invitations and join requests | Their entity slices plus about 15 effects in project-detail.effects.ts |
| R3.3 | Stages and stage settings | Stage slice plus its effects |
| R3.4 | Annotation questions (project level; AF2 runtime excluded) | Question slice plus its effects |
| R3.5 | Searches, import jobs, bulk update jobs and data-export jobs; SignalR job events through the Events plugin | Search, import and export slices and effects, s3-file.service actions |
| R3.6 | Project statistics reads (screening and annotation summaries) via resources | Stats effects and selectors |
Per-PR acceptance criteria are C1–C7, plus the following.
| # | Acceptance criterion | Verification |
|---|---|---|
| 3.1 | When the collection's PR merges, its reducer, selectors, effects and normalizr schema are deleted | Inventory; tsc |
| 3.2 | SignalR updates for the collection reach the UI without reload, including after a reconnect (bug B1's fix keeps working) | E2E (two contexts, offline/online) |
| 3.3 | Every write reports success or failure to the UI. A double submit creates one entity, and ordered edits apply in order | Store specs |
| 3.4 | After R3.6, project-detail.effects.ts is deleted |
Inventory |
R4: studies, stage review and AF2 (sequenced with their owners)¶
- Study table and study management: the R3 patterns, done by the owning stream or by this plan once it is idle.
- Stage review:
stage-review.component.ts(2,216 lines) andreview-effects.tsare migrated only with the stage-review programme owner's agreement (D4). - AF2: already a SignalStore. Here the work is splitting its 3,000-line store into
signalStoreFeatures by concern, not migrating it, and that is the AF2 programme's call. - Notifications and study-attention: in flight in other sessions (#3932, #3938–#3947, #3965). Their new code should follow the ADR from R0.3; they are not reworked by this plan.
| # | Acceptance criterion | Verification |
|---|---|---|
| 4.1 | Each area's old-idiom count reaches 0 when its PR merges | Inventory |
| 4.2 | Review flows (claim next study, submit, delete session) keep their semantics. Two rapid "next study" actions claim at most one study | Store specs plus review E2E |
R5: forms (alongside R2–R4)¶
Move ngrx-forms (9 files) and UntypedForm* (40 files) to signal forms, area by area, normally in the same PR that migrates the area's state.
| # | Acceptance criterion | Verification |
|---|---|---|
| 5.1 | Migrated forms keep their validation messages, enablement and submit behaviour | Component specs; preview |
| 5.2 | When R5 completes, ngrx-forms is uninstalled and the UntypedForm* count is 0 |
Inventory; package.json |
R6: removal (final)¶
Delete core/state (store setup, router store, meta-reducers, request-state layer) and uninstall @ngrx/store, @ngrx/effects, @ngrx/entity, @ngrx/router-store, @ngrx/store-devtools, @ngrx/component-store, @rx-angular/state, normalizr, ngrx-forms and logrocket-ngrx. Keep @ngrx/signals and @ngrx/operators.
| # | Acceptance criterion | Verification |
|---|---|---|
| 6.1 | The inventory reports 0 for every retired idiom | Script output |
| 6.2 | The removed packages are gone from package.json and the lockfile |
Diff |
| 6.3 | The production bundle is smaller than before R0. The size drop is recorded, with no target (PROPOSAL: record only) | ng build stats |
| 6.4 | Full E2E smoke plus the area E2E specs added in R2–R4 pass | E2E run |
5b. Realtime and routing¶
Superseded in part (2026-10-04). The realtime client plan §4 replaces points 1–9, RT.1–RT.8, 2.R6 and the R0.2
RealtimeStorebullet, and answers 2.R5. 2.R4 and the ownership table stay here. The server side and hub contract v2 are in the realtime server plan.
This section covers how SignalR subscriptions, route resources and SignalStores stay in sync. It adds pieces to R0.2 (the kit) and checks to R2 (the pilot), and R3 depends on it.
Ownership: one owner per piece of data¶
| Data | Owner | Lifetime | How SignalR keeps it live |
|---|---|---|---|
| Page data for the current route (project details, stage details) | Route resource, wrapped by a route-scoped SignalStore | From navigation to the route until leaving it | On a relevant event, the store calls resource.reload(), debounced and coalesced; the server stays the source of truth |
| Shared collections used across pages or outliving a page (project listings, export jobs, inbox, presence, progress) | Root SignalStores with withEntities |
App lifetime | Events update entities in place, applied only if newer (version check) |
| Page UI state (filters, selection, drafts) | Route-scoped SignalStore in the route's providers |
Destroyed with the route (withAutoCleanupInjectors) |
Not applicable |
Components inject stores only. They never read ActivatedRoute.resources or the hub directly.
Route resources inside a route-scoped SignalStore¶
- The store wraps the route's resources: reads through
withProps/withComputed; writes through methods that call the API and thenreload()(or patch the owning root store); andwithEventHandlersfor this page's realtime events. - The router types a route resource as read-only plus
reload(), so page data refreshes by reload rather than local patching. High-frequency, patch-in-place data lives in root stores. - Fallback if a router-resources check fails: the route-scoped store owns a route-param-keyed
httpResourceitself. Nothing else changes.
Declarative SignalR subscriptions (RealtimeStore, part of the R0.2 kit)¶
- A root
RealtimeStoreowns theHubConnectionand exposesconnectionIdandstateas signals. - Anything on screen declares the groups it needs for its own lifetime:
realtime.need(() => isMember() ? projectGroup(projectId()) : null). - The project route store declares the project group.
- The project-index store declares listings.
- The export store declares one group per unresolved job (a computed list).
- The review page declares presence.
- A ref-counted registry reconciles the desired set (the union of live declarations) against what is subscribed on the current connection ID. It invokes
Subscribe…/Unsubscribe…for the difference, catches rejections and retries failed subscribes. - When a route is left, its store is disposed, its declarations drop and its groups are unsubscribed. Navigating from project A to project B swaps the group.
- When the connection ID changes after a reconnect, "subscribed" resets, so every declared group is re-subscribed. Bug B1 cannot recur by construction.
- Events sent during an outage are lost. On a new connection the store emits
realtimeEvents.resynced: route stores reload their resources, and root stores refetch active collections. - Hub handlers become NgRx Events plugin events (
realtimeEvents.projectChanged,exportJobProgress,listingChangedand so on). Producers never know their consumers. - Stale or out-of-order data:
- Root stores apply a patch only if its aggregate version is newer than the one held.
- Route resources reload instead of patching, coalescing a reload that arrives during a load.
- If the SignalR DTOs lack
Audit.Version, root stores use reload for those entities (checked in R2). - Declarations are conditional on membership, from the route resource. The server keeps enforcing it.
Acceptance criteria¶
| # | Criterion (condition → result) | Verification |
|---|---|---|
| RT.1 | A component declares group G and is destroyed → G is unsubscribed, unless another live declaration still needs it (ref count) | RealtimeStore unit spec (fake hub) |
| RT.2 | Navigate from project A to project B → the hub receives UnsubscribeFromProject(A) and SubscribeToProject(B), in that order, once each |
Unit spec plus E2E (hub invocations recorded in the hermetic stack) |
| RT.3 | The connection ID changes (reconnect) → every live declaration is re-subscribed exactly once on the new connection, and resynced is emitted once |
Unit spec |
| RT.4 | Two contexts on project P; one goes offline for 10 s (PROPOSAL) and comes back → edits made during the outage appear after resync, without reload | E2E |
| RT.5 | A Subscribe… invoke rejects → it is retried with backoff, and no unhandled rejection occurs |
Unit spec |
| RT.6 | A stale event (older version) reaches a root store → the entity is unchanged | Store spec |
| RT.7 | A projectChanged event arrives while the project resource is loading → one reload follows, and the final value matches the server |
Store spec |
| RT.8 | The user is not a member of project P → no project-P group is ever requested | Unit spec |
Checks added to the R2 pilot (they feed the go/no-go gate)¶
| # | Check | Outcome recorded |
|---|---|---|
| 2.R4 | Route-level providers can inject ActivatedRoute and the route's resources, and whether the resources function runs in the route injector (so a store could create the resource and keep the writable handle) |
Spike spec plus an ADR note; this chooses between the primary design and the fallback |
| 2.R5 | Whether the SignalR DTOs carry an aggregate version | ADR note; this decides patch versus reload per entity |
| 2.R6 | The project-index pilot runs on RealtimeStore declarations (listings group), with RT.1–RT.5 passing |
Pilot PR |
Relationship to bug-plan PR-3: PR-3 ships now as a targeted fix inside the existing signal-r.service.ts. RealtimeStore replaces that service area by area: listings in R2, project groups in R3.1, export jobs in R3.5 and presence in R4. The old service is deleted in R6.
6. Order¶
flowchart LR
R01[R0.1 upgrade] --> R02[R0.2 kit] --> R2[R2 pilot] --> GATE{{Chris go/no-go}} --> R3[R3.1→R3.6 project domain] --> R6[R6 removal]
R03[R0.3 ADR] --> R2
R04[R0.4 ratchet] --> R1[R1 retirements]
GATE --> R4[R4 studies/review/AF2 with owners]
R2 -.-> R5[R5 forms, alongside]
R4 --> R6
- R0.1 comes first.
- R0.3 and R0.4 can run in parallel with R0.2.
- R1 can start once R0.4 lands.
- The critical path is R0.1 → R0.2 → R2 → gate → R3 → R6.
7. Risks¶
| Risk | Mitigation |
|---|---|
| Migration stalls halfway, leaving two idioms for a long time | Ratchet (R0.4), a pilot gate, one collection per PR, and an inventory published in each PR |
| Hidden behaviour in effects (retry loops, snackbars, correlation IDs) is lost | Parity criterion C1 and specs translated one-for-one; correlation-ID waiters are replaced by method return values in the same PR |
| Experimental or community APIs change | Wrapped in local signalStoreFeatures (rule 4), so the blast radius is one file |
| Collisions with active streams (notifications, AF2, review) | Their areas come last or with their owners (R4); open PRs are checked before each slice |
| Debuggability drops without global devtools | withDevtools in development from R0.2 |
LogRocket NgRx integration (logrocket-ngrx) disappears |
D5 |
8. Relationship to the frontend bug-fix plan¶
The bugs ship first, as small targeted fixes, because they affect users now. Some fixed code is later replaced by R3; that is expected.
Two items are deliberately shared:
- toApiError(): bug PR-7 or R0.2, whichever lands first.
- Current project from the route: bug PR-6 introduces the single writer; R3.1 replaces it with route derivation.
9. Decisions needed from Chris¶
- D1. Boundary rule for events. Use the Events plugin only for producer-agnostic flows (SignalR, cross-feature), and plain store methods everywhere else. Recommended.
- D2. Experimental and community APIs. Allow
@ngrx/signals/resource(experimental) and ngrx-toolkit features where they cut boilerplate, wrapped per rule 4. Recommended: yes forwithDevtools, case by case for the others, with Angular's own resources first. - D3. Pilot area. The project index (R2). It is self-contained and exercises resources, entities, events and SignalR. Recommended.
- D4. Areas owned by other streams. Stage review, AF2, the v1 annotation form's retirement (which removes rx-angular) and notifications: should their owners migrate them, or this plan after their current work?
- D5. LogRocket.
logrocket-ngrxonly works with the global store. Keep LogRocket (a GDPR question raised in the review) and find another integration, or drop it? - D6. Pace. Dedicated slices as above, or rely mainly on "modernise as you touch it" after R0–R2? Recommended: dedicated slices for R3, because
project-detail.effects.tswill not shrink by itself. - D8. Router resources (developer preview 22.2). Adopt them for project and stage page data, if the R2 check passes. Recommended: they replace most of the custom load, guard and current-project machinery with framework code.
- D7. PROPOSAL values: bundle growth ≤ 2% at R0.1; pilot p95 latency within 10%.