@signaltree/enterprise 13.1.0 → 13.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skills/using-signaltree/SKILL.md +153 -0
- package/skills/using-signaltree/enterprise/SKILL.md +95 -0
- package/skills/using-signaltree/reference/anti-patterns.md +266 -0
- package/skills/using-signaltree/reference/core.md +398 -0
- package/skills/using-signaltree/reference/install.md +110 -0
- package/skills/using-signaltree/reference/migration-from-ngrx-signals.md +971 -0
- package/skills/using-signaltree/reference/migration-from-ngrx-store.md +396 -0
- package/skills/using-signaltree/reference/optimal-implementation.md +211 -0
- package/skills/using-signaltree/reference/orchestrating-a-migration.md +364 -0
- package/skills/using-signaltree/reference/patterns.md +1250 -0
- package/skills/using-signaltree/reference/testing.md +282 -0
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
# Patterns
|
|
2
|
+
|
|
3
|
+
Idiomatic SignalTree usage. Every snippet compiles against `@signaltree/core` (and `@angular/core` where shown) without modification.
|
|
4
|
+
|
|
5
|
+
## Prefer a single global store
|
|
6
|
+
|
|
7
|
+
For app-wide state, lean toward **one tree per application** rather than many
|
|
8
|
+
small trees. A single tree:
|
|
9
|
+
|
|
10
|
+
- lets you derive cross-domain values naturally (one ticket depends on one
|
|
11
|
+
driver depends on one truck — all on the same `$`);
|
|
12
|
+
- gives you one DevTools timeline, so "what happened right before that bug?"
|
|
13
|
+
has a single answer;
|
|
14
|
+
- keeps initialization order explicit — the tree is composed once at app boot,
|
|
15
|
+
not assembled from dozens of services that each hold a fragment.
|
|
16
|
+
|
|
17
|
+
This is a soft preference, not a rule. Genuinely component-local state
|
|
18
|
+
(a form, a popover, a list being edited) still belongs on the component. And
|
|
19
|
+
teams migrating from `@ngrx/signals` often have many small `signalStore`s —
|
|
20
|
+
you don't have to collapse them all day one. The shape below is the target,
|
|
21
|
+
not a prerequisite.
|
|
22
|
+
|
|
23
|
+
### Shape
|
|
24
|
+
|
|
25
|
+
One file per domain exposing a plain state factory:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// tickets.state.ts
|
|
29
|
+
import { entityMap } from '@signaltree/core';
|
|
30
|
+
|
|
31
|
+
interface Ticket {
|
|
32
|
+
id: number;
|
|
33
|
+
title: string;
|
|
34
|
+
done: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ticketsState() {
|
|
38
|
+
return {
|
|
39
|
+
entities: entityMap<Ticket, number>(),
|
|
40
|
+
activeId: null as number | null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// identity.state.ts
|
|
47
|
+
interface User {
|
|
48
|
+
id: number;
|
|
49
|
+
name: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function identityState() {
|
|
53
|
+
return {
|
|
54
|
+
user: null as User | null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A single `createAppTree()` composes them and layers derived tiers. Each
|
|
60
|
+
`.derived(...)` call can read signals added by previous tiers — that's why
|
|
61
|
+
multi-tier derivations chain rather than collapse into one block:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// app-tree.ts
|
|
65
|
+
import { InjectionToken, Provider, computed } from '@angular/core';
|
|
66
|
+
import { signalTree, batching, devTools, timeTravel } from '@signaltree/core';
|
|
67
|
+
|
|
68
|
+
// Local minimal state factories for the snippet to compile standalone.
|
|
69
|
+
interface Ticket {
|
|
70
|
+
id: number;
|
|
71
|
+
title: string;
|
|
72
|
+
done: boolean;
|
|
73
|
+
}
|
|
74
|
+
interface User {
|
|
75
|
+
id: number;
|
|
76
|
+
name: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
import { entityMap } from '@signaltree/core';
|
|
80
|
+
function ticketsState() {
|
|
81
|
+
return {
|
|
82
|
+
entities: entityMap<Ticket, number>(),
|
|
83
|
+
activeId: null as number | null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function identityState() {
|
|
87
|
+
return { user: null as User | null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createBaseState() {
|
|
91
|
+
return {
|
|
92
|
+
tickets: ticketsState(),
|
|
93
|
+
identity: identityState(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createAppTree() {
|
|
98
|
+
return (
|
|
99
|
+
signalTree(createBaseState())
|
|
100
|
+
.with(devTools({ treeName: 'AppTree' }))
|
|
101
|
+
.with(batching())
|
|
102
|
+
.with(timeTravel())
|
|
103
|
+
// Tier 1: entity resolution off raw state.
|
|
104
|
+
.derived(($) => ({
|
|
105
|
+
tickets: {
|
|
106
|
+
active: {
|
|
107
|
+
entity: computed(() => {
|
|
108
|
+
const id = $.tickets.activeId();
|
|
109
|
+
return id != null ? $.tickets.entities.byId(id)?.() ?? null : null;
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
all: computed(() => $.tickets.entities.all()),
|
|
113
|
+
},
|
|
114
|
+
}))
|
|
115
|
+
// Tier 2: business logic depending on Tier 1.
|
|
116
|
+
.derived(($) => ({
|
|
117
|
+
tickets: {
|
|
118
|
+
hasActive: computed(() => $.tickets.active.entity() != null),
|
|
119
|
+
openCount: computed(() => $.tickets.all().filter((t) => !t.done).length),
|
|
120
|
+
},
|
|
121
|
+
}))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type AppTree = ReturnType<typeof createAppTree>;
|
|
126
|
+
|
|
127
|
+
export const APP_TREE = new InjectionToken<AppTree>('APP_TREE');
|
|
128
|
+
|
|
129
|
+
export function provideAppTree(): Provider[] {
|
|
130
|
+
return [{ provide: APP_TREE, useFactory: () => createAppTree() }];
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Brown-field migrations: declare `APP_TREE` with a tree-shakable factory
|
|
135
|
+
|
|
136
|
+
In a brown-field migration where dozens (or hundreds) of existing `TestBed`s never opt into `provideAppTreeForTesting()`, every spec that transitively touches `AppStore` will fail with `NullInjectorError: APP_TREE` the moment the migration lands. Editing every spec to add the provider is mechanical noise and a poor reviewer experience.
|
|
137
|
+
|
|
138
|
+
**Solution:** declare the `APP_TREE` token itself with a `providedIn: 'root'` factory. Each child injector (including the per-spec `TestBed` injector) gets a fresh, isolated tree by default. Explicit `provideAppTree()` in `bootstrapApplication` and explicit `provideAppTreeForTesting(seed)` in a spec both still win, because they register a higher-priority provider on the consuming injector.
|
|
139
|
+
|
|
140
|
+
```ts skip
|
|
141
|
+
// tree/app-tree.ts
|
|
142
|
+
export const APP_TREE = new InjectionToken<AppTree>('APP_TREE', {
|
|
143
|
+
providedIn: 'root',
|
|
144
|
+
factory: () => createAppTree(), // full enhancers; tests get isolation because the factory runs per child injector
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Two notes that bite if you skip them:
|
|
149
|
+
|
|
150
|
+
- **The factory must return `createAppTree()`, not `signalTree(createBaseState())`.** `AppTree = ReturnType<typeof createAppTree>` includes the enhancer methods (`devTools` connection handle, `timeTravel` history API, etc.). A bare `signalTree(createBaseState())` is structurally narrower and will fail to satisfy `AppTree` with `TS2769`. If you genuinely want a bare tree as the default (for example to keep tests free of DevTools chatter), define `AppTree = ReturnType<typeof signalTree<ReturnType<typeof createBaseState>>>` and have `createAppTree()` return that same narrower type — but then production code that needs the enhancer surface must reach for them via the builder, not via `inject(APP_TREE)`.
|
|
151
|
+
- Each child injector still gets its own tree because the factory runs per injector — enhancers don't leak state between specs.
|
|
152
|
+
|
|
153
|
+
This is the recommended default for any migration into an existing app. Greenfield apps where every `TestBed` is authored against the new shape can keep the bare `new InjectionToken<AppTree>('APP_TREE')` form.
|
|
154
|
+
|
|
155
|
+
### Splitting derived tiers into separate files
|
|
156
|
+
|
|
157
|
+
When a tier grows beyond ~30 lines, move it into its own file under
|
|
158
|
+
`tree/derived/`. Two practical conventions keep this readable as the tree
|
|
159
|
+
grows:
|
|
160
|
+
|
|
161
|
+
1. **Name tiers by what they do, not by their position.** `tier-entity-resolution.derived.ts`,
|
|
162
|
+
`tier-ticket-workflow.derived.ts`, `tier-ui-aggregates.derived.ts` survive
|
|
163
|
+
refactors. `tier-1`, `tier-2`, … force a rename whenever a tier moves.
|
|
164
|
+
2. **Type each tier function against the tree shape it actually sees.** Use
|
|
165
|
+
`derivedFrom<TTree>()` to declare the
|
|
166
|
+
tier in its own file with a fully typed `$`, and `WithDerived<…>` to
|
|
167
|
+
describe the tree shape after each tier so subsequent tiers can reference
|
|
168
|
+
what came before.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// @skip-lint - illustrative cross-file imports; resolved at app build time.
|
|
172
|
+
// tree/app-tree.ts
|
|
173
|
+
import { signalTree, WithDerived } from '@signaltree/core';
|
|
174
|
+
import { entityResolutionDerived } from './derived/tier-entity-resolution.derived';
|
|
175
|
+
import { ticketWorkflowDerived } from './derived/tier-ticket-workflow.derived';
|
|
176
|
+
|
|
177
|
+
function createBaseState() {
|
|
178
|
+
/* …state factories… */ return {} as Record<string, unknown>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Tree shape before any derived tiers — what tier-1 sees. */
|
|
182
|
+
export type AppTreeBase = ReturnType<typeof signalTree<ReturnType<typeof createBaseState>>>;
|
|
183
|
+
|
|
184
|
+
/** Tree shape after entity resolution — what tier-2 sees. */
|
|
185
|
+
export type AppTreeWithEntityResolution = WithDerived<AppTreeBase, typeof entityResolutionDerived>;
|
|
186
|
+
|
|
187
|
+
/** Tree shape after ticket workflow — what tier-3 sees, and so on. */
|
|
188
|
+
export type AppTreeWithTicketWorkflow = WithDerived<AppTreeWithEntityResolution, typeof ticketWorkflowDerived>;
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// @skip-lint - relative import to sibling app-tree file shown above.
|
|
193
|
+
// tree/derived/tier-entity-resolution.derived.ts
|
|
194
|
+
import { computed } from '@angular/core';
|
|
195
|
+
import { derivedFrom } from '@signaltree/core';
|
|
196
|
+
import type { AppTreeBase } from '../app-tree';
|
|
197
|
+
|
|
198
|
+
export const entityResolutionDerived = derivedFrom<AppTreeBase>()(($) => ({
|
|
199
|
+
tickets: {
|
|
200
|
+
active: computed(() => {
|
|
201
|
+
const id = $.tickets.activeId();
|
|
202
|
+
return id != null ? $.tickets.entities.byId(id)?.() ?? null : null;
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
}));
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// @skip-lint - relative import to sibling app-tree file shown above.
|
|
210
|
+
// tree/derived/tier-ticket-workflow.derived.ts
|
|
211
|
+
import { computed } from '@angular/core';
|
|
212
|
+
import { derivedFrom } from '@signaltree/core';
|
|
213
|
+
import type { AppTreeWithEntityResolution } from '../app-tree';
|
|
214
|
+
|
|
215
|
+
// Sees `tickets.active` from the previous tier, fully typed.
|
|
216
|
+
export const ticketWorkflowDerived = derivedFrom<AppTreeWithEntityResolution>()(($) => ({
|
|
217
|
+
tickets: {
|
|
218
|
+
canAdvance: computed(() => $.tickets.active() != null),
|
|
219
|
+
},
|
|
220
|
+
}));
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The chain in `createAppTree()` is unchanged — `.derived(entityResolutionDerived).derived(ticketWorkflowDerived)` — but each tier file is independently typed and reviewable.
|
|
224
|
+
|
|
225
|
+
### Recommended tier ladder for large apps
|
|
226
|
+
|
|
227
|
+
Once a tree has more than ~3 domains with computeds, ad-hoc `computed(...)` calls scattered across state factories become hard to reason about (which signal depends on which?). Validated production trees converge on a five-tier ladder, each tier strictly building on the one below:
|
|
228
|
+
|
|
229
|
+
| Tier | Name | Sees | Job |
|
|
230
|
+
| ---- | ----------------- | ----------------------------- | -------------------------------------------------------------------------------- |
|
|
231
|
+
| 0 | Base state | — | Raw data: `entityMap`s, primitive leaves, `status()` slices. No computeds. |
|
|
232
|
+
| 1 | Entity resolution | `AppTreeBase` | Resolve `*Id` leaves to full entities via `entityMap.byId()`. Pure lookup. |
|
|
233
|
+
| 2 | Complex logic | `AppTreeWithEntityResolution` | Business rules over resolved entities (display names, isExternal, isComplete). |
|
|
234
|
+
| 3 | Workflow | `AppTreeWithComplexLogic` | Domain-specific state machines (workflow steps, current index, status maps). |
|
|
235
|
+
| 4 | Navigation | `AppTreeWithWorkflow` | Position queries on top of workflow (next/previous, canAdvance, statusInfo). |
|
|
236
|
+
| 5 | UI aggregates | `AppTreeWithNavigation` | Cross-domain rollups for shells / error banners (overall loading, firstError). |
|
|
237
|
+
|
|
238
|
+
**Why these specific layers?** Each one answers a different question and depends only on lower layers, so the dependency graph is always acyclic by construction:
|
|
239
|
+
|
|
240
|
+
- **Tier 1 (entity resolution) MUST come first** — every higher tier wants to talk about entities, not IDs. Doing this lookup once removes a class of bugs where two tiers resolve the same id with different fallbacks.
|
|
241
|
+
- **Tier 2 (complex logic) is where business rules live** — `displayName`, `isComplete`, `isExternal`. If you find yourself writing the same `computed` in two components, it belongs here.
|
|
242
|
+
- **Tier 3+4 (workflow / navigation) split when state-machine code grows** — keep the steps array and current-index in workflow, keep next/previous and `canAdvance` in navigation. Splitting prevents one giant tier file from accumulating every workflow query in the app.
|
|
243
|
+
- **Tier 5 (UI aggregates) is the only tier that touches more than one domain** — overall loading, first-error, has-any-error. Components consume from here so they don't have to OR-together five domain signals inline.
|
|
244
|
+
|
|
245
|
+
**Type wiring at scale.** Each tier file imports the previous-tier shape it needs:
|
|
246
|
+
|
|
247
|
+
```ts skip
|
|
248
|
+
// tree/app-tree.ts
|
|
249
|
+
import { signalTree, WithDerived } from '@signaltree/core';
|
|
250
|
+
import { entityResolutionDerived } from './derived/tier-entity-resolution.derived';
|
|
251
|
+
import { complexLogicDerived } from './derived/tier-complex-logic.derived';
|
|
252
|
+
import { ticketWorkflowDerived } from './derived/tier-ticket-workflow.derived';
|
|
253
|
+
import { ticketNavigationDerived } from './derived/tier-ticket-navigation.derived';
|
|
254
|
+
import { uiAggregatesDerived } from './derived/tier-ui-aggregates.derived';
|
|
255
|
+
|
|
256
|
+
export type AppTreeBase = ReturnType<typeof signalTree<ReturnType<typeof createBaseState>>>;
|
|
257
|
+
export type AppTreeWithEntityResolution = WithDerived<AppTreeBase, typeof entityResolutionDerived>;
|
|
258
|
+
export type AppTreeWithComplexLogic = WithDerived<AppTreeWithEntityResolution, typeof complexLogicDerived>;
|
|
259
|
+
export type AppTreeWithWorkflow = WithDerived<AppTreeWithComplexLogic, typeof ticketWorkflowDerived>;
|
|
260
|
+
export type AppTreeWithNavigation = WithDerived<AppTreeWithWorkflow, typeof ticketNavigationDerived>;
|
|
261
|
+
export type AppTree = ReturnType<typeof createAppTree>; // final, post-uiAggregates
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Consumer rule:** components and Ops always type against `AppTree` (the final composed shape). Only tier files type against intermediate phases. This keeps consumer code stable when tiers are reordered or added.
|
|
265
|
+
|
|
266
|
+
**When to skip the ladder.** A small app (≤ 2 domains, ≤ 5 computeds total) does not need this — inline `computed`s in state factories are fine. Adopt the ladder when:
|
|
267
|
+
|
|
268
|
+
- Total derived signals exceed ~15 across the tree, OR
|
|
269
|
+
- Two or more components import the same `computed` from different state files, OR
|
|
270
|
+
- Cross-domain rollups appear (the moment one computed reads from two state slices, you want UI aggregates).
|
|
271
|
+
|
|
272
|
+
Do NOT pre-build empty tiers — start with whatever tiers you actually have signals for, and add tiers only when crossing the boundary creates real value. The ladder is a destination, not a starting template.
|
|
273
|
+
|
|
274
|
+
### `AppStore` facade
|
|
275
|
+
|
|
276
|
+
A single `providedIn: 'root'` class holds the tree, exposes `$`, and namespaces
|
|
277
|
+
domain operations under `ops`. Cross-domain orchestration lives as methods on
|
|
278
|
+
this class.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import { inject, Injectable, InjectionToken, Signal } from '@angular/core';
|
|
282
|
+
import { signalTree } from '@signaltree/core';
|
|
283
|
+
|
|
284
|
+
// Declared elsewhere in the real app (see "Shape" above).
|
|
285
|
+
interface AppState {
|
|
286
|
+
tickets: { activeId: number | null };
|
|
287
|
+
identity: { user: { id: number; name: string } | null };
|
|
288
|
+
}
|
|
289
|
+
declare const APP_TREE: InjectionToken<ReturnType<typeof signalTree<AppState>>>;
|
|
290
|
+
|
|
291
|
+
@Injectable({ providedIn: 'root' })
|
|
292
|
+
class TicketOps {
|
|
293
|
+
clearAll(): void {
|
|
294
|
+
/* ... */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
@Injectable({ providedIn: 'root' })
|
|
298
|
+
class IdentityOps {
|
|
299
|
+
clear(): void {
|
|
300
|
+
/* ... */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@Injectable({ providedIn: 'root' })
|
|
305
|
+
export class AppStore {
|
|
306
|
+
readonly tree = inject(APP_TREE);
|
|
307
|
+
readonly $ = this.tree.$;
|
|
308
|
+
|
|
309
|
+
readonly ops = {
|
|
310
|
+
tickets: inject(TicketOps),
|
|
311
|
+
identity: inject(IdentityOps),
|
|
312
|
+
} as const;
|
|
313
|
+
|
|
314
|
+
/** Cross-domain orchestration — coordinates multiple Ops. */
|
|
315
|
+
logout(): void {
|
|
316
|
+
this.ops.identity.clear();
|
|
317
|
+
this.ops.tickets.clearAll();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### `Ops` class per domain
|
|
323
|
+
|
|
324
|
+
One `@Injectable({ providedIn: 'root' })` class per domain owns the mutators
|
|
325
|
+
for that slice. Ops classes are **stateless facades** — no fields other than
|
|
326
|
+
injected dependencies and a cached reference to the relevant slice of `$`.
|
|
327
|
+
Async operations return observables so callers control subscription lifetime.
|
|
328
|
+
For most async needs, **prefer the `asyncSource` and `asyncQuery` markers** in
|
|
329
|
+
the tree literal — they eliminate this Ops-method pattern entirely. Use the
|
|
330
|
+
Ops-method pattern when you need explicit `Observable<void>` returns the
|
|
331
|
+
caller subscribes to (e.g., chained workflows). See "Replacing `rxMethod`"
|
|
332
|
+
below for the full mapping.
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { inject, Injectable, InjectionToken } from '@angular/core';
|
|
336
|
+
import type { Observable } from 'rxjs';
|
|
337
|
+
import { map, tap } from 'rxjs/operators';
|
|
338
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
339
|
+
|
|
340
|
+
interface Ticket {
|
|
341
|
+
id: number;
|
|
342
|
+
title: string;
|
|
343
|
+
done: boolean;
|
|
344
|
+
}
|
|
345
|
+
interface AppState {
|
|
346
|
+
tickets: {
|
|
347
|
+
entities: ReturnType<typeof entityMap<Ticket, number>>;
|
|
348
|
+
activeId: number | null;
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
declare const APP_TREE: InjectionToken<ReturnType<typeof signalTree<AppState>>>;
|
|
352
|
+
interface TicketApi {
|
|
353
|
+
list$(): Observable<Ticket[]>;
|
|
354
|
+
}
|
|
355
|
+
declare const TICKET_API: InjectionToken<TicketApi>;
|
|
356
|
+
|
|
357
|
+
@Injectable({ providedIn: 'root' })
|
|
358
|
+
export class TicketOps {
|
|
359
|
+
private readonly _tree = inject(APP_TREE);
|
|
360
|
+
private readonly _$tickets = this._tree.$.tickets;
|
|
361
|
+
|
|
362
|
+
private readonly _api = inject(TICKET_API);
|
|
363
|
+
|
|
364
|
+
// Sync mutations.
|
|
365
|
+
setActive(id: number | null): void {
|
|
366
|
+
this._$tickets.activeId.set(id);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
clearAll(): void {
|
|
370
|
+
this._$tickets.entities.setAll([]);
|
|
371
|
+
this._$tickets.activeId.set(null);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Async — observable return, caller owns subscription.
|
|
375
|
+
load$(): Observable<void> {
|
|
376
|
+
return this._api.list$().pipe(
|
|
377
|
+
tap((items) => this._$tickets.entities.setAll(items, { selectId: (t) => t.id })),
|
|
378
|
+
map(() => void 0)
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Component usage
|
|
385
|
+
|
|
386
|
+
**Injection rules — follow these exactly:**
|
|
387
|
+
|
|
388
|
+
- **Components, resolvers, interceptors, guards, and any other consumer inject `AppStore` only.** Never inject an Ops class or `APP_TREE` directly in a consumer.
|
|
389
|
+
- **Ops classes inject `APP_TREE` directly** for writes, and may inject other services. They do not inject `AppStore`.
|
|
390
|
+
- **`APP_TREE` token is private infrastructure.** Only `AppStore` and Ops classes should ever inject it.
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
Consumer (component / resolver / interceptor)
|
|
394
|
+
└─ injects AppStore
|
|
395
|
+
├─ $.domain.leaf() ← reads
|
|
396
|
+
└─ ops.domain.method() ← writes (delegates to Ops class)
|
|
397
|
+
|
|
398
|
+
Ops class
|
|
399
|
+
└─ injects APP_TREE ← writes directly to the tree
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Components read via `store.$` and trigger mutations via `store.ops.<domain>`:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
import { Component, inject } from '@angular/core';
|
|
406
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
407
|
+
|
|
408
|
+
interface Ticket {
|
|
409
|
+
id: number;
|
|
410
|
+
title: string;
|
|
411
|
+
done: boolean;
|
|
412
|
+
}
|
|
413
|
+
interface AppState {
|
|
414
|
+
tickets: {
|
|
415
|
+
entities: ReturnType<typeof entityMap<Ticket, number>>;
|
|
416
|
+
activeId: number | null;
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Minimal ambient store/ops shapes for the snippet.
|
|
421
|
+
declare class TicketOps {
|
|
422
|
+
load$(): import('rxjs').Observable<void>;
|
|
423
|
+
setActive(id: number | null): void;
|
|
424
|
+
}
|
|
425
|
+
declare class AppStore {
|
|
426
|
+
readonly $: ReturnType<typeof signalTree<AppState>>['$'];
|
|
427
|
+
readonly ops: { tickets: TicketOps };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
@Component({ selector: 'app-tickets', template: '' })
|
|
431
|
+
export class TicketsComponent {
|
|
432
|
+
readonly store = inject(AppStore);
|
|
433
|
+
readonly tickets = this.store.$.tickets.entities.all;
|
|
434
|
+
readonly activeId = this.store.$.tickets.activeId;
|
|
435
|
+
|
|
436
|
+
load() {
|
|
437
|
+
this.store.ops.tickets.load$().subscribe();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### Rules of thumb
|
|
443
|
+
|
|
444
|
+
- **State lives in the tree.** Never on the Ops class or `AppStore`.
|
|
445
|
+
- **Derived lives in `.derived()`.** Never on the Ops class.
|
|
446
|
+
- **Ops classes are stateless facades.** No fields other than injected dependencies and a cached slice of `$`.
|
|
447
|
+
- **Cross-domain orchestration goes on `AppStore`.** Not on any one Ops.
|
|
448
|
+
- **One tree per application.** Keep component-local trees only for genuinely ephemeral UI state.
|
|
449
|
+
- **Consumers inject `AppStore`, nothing else.** A component, resolver, interceptor, or guard that injects an Ops class or `APP_TREE` directly is wrong — route everything through `AppStore`.
|
|
450
|
+
- **`APP_TREE` belongs to infrastructure.** Only `AppStore` and Ops classes ever inject the token.
|
|
451
|
+
- **Tests must provide `APP_TREE`.** `providedIn: 'root'` makes `AppStore` ambient — Angular instantiates it (and any consumer that depends on it) inside every `TestBed`, which then fails with `NG0201` because production `provideAppTree()` is never called. Ship `provideAppTreeForTesting()` alongside `provideAppTree()` from day one. See [`testing.md`](./testing.md).
|
|
452
|
+
|
|
453
|
+
### Lifetime caveat for `providedIn: 'root'` Ops
|
|
454
|
+
|
|
455
|
+
Ops classes are typically `@Injectable({ providedIn: 'root' })` so consumers can call them anywhere. That makes them **application-lifetime** — `DestroyRef` injected into a root Ops never fires until the app shuts down, so `takeUntilDestroyed(destroyRef)` inside a root-provided Ops is effectively a no-op.
|
|
456
|
+
|
|
457
|
+
This is fine for fire-and-forget reads (the subscription naturally completes when the source completes), but be deliberate when:
|
|
458
|
+
|
|
459
|
+
- **You need cancellation between user actions** (a new `loadDriver$()` call should cancel the previous one). Use `switchMap` inside the Ops on a `Subject<TInput>`, not `takeUntilDestroyed`.
|
|
460
|
+
- **The Ops drives an infinite stream** (websocket, polling). Hold an explicit `Subject<void>` cancellation token and expose `cancelLoad(): void { this._cancel$.next(); }`.
|
|
461
|
+
- **Tests need cleanup between specs.** Either drive Ops via `firstValueFrom(...)` so completion is explicit, or expose an explicit cancel hook the test bed can call in `afterEach`.
|
|
462
|
+
|
|
463
|
+
If you need per-request scoping rather than app-lifetime, drop `providedIn: 'root'` from the Ops and provide it on a feature route or a sub-injector instead. Don't reach for `takeUntilDestroyed` to fix what is really a scoping problem.
|
|
464
|
+
|
|
465
|
+
## Hybrid migration: legacy facade adapters (fallback)
|
|
466
|
+
|
|
467
|
+
> **Default is big-bang**, not hybrid. The hybrid pattern below is a _temporary scaffold_ for migrations that cannot land in one PR. See [`optimal-implementation.md`](./optimal-implementation.md#when-the-hybrid-pattern-is-acceptable) for when this is acceptable, and what deletion-deadline metadata you must ship with each adapter.
|
|
468
|
+
|
|
469
|
+
When big-bang isn't possible — the existing app exposes a long-standing facade like an `@ngrx/signals` `DriverStore`, dozens of consumers depend on it, and you cannot flip them all in one PR — two-step the migration:
|
|
470
|
+
|
|
471
|
+
1. **Stand up the new shape first.** Create `AppStore`, `Ops` classes, and `APP_TREE` exactly as in [Shape](#shape).
|
|
472
|
+
2. **Replace the legacy facade's internals with adapters over `AppStore`.** Keep the legacy class name and public signature so consumers and specs keep compiling. Delete the legacy facade only when zero consumers remain.
|
|
473
|
+
|
|
474
|
+
The adapter is a _typed view_ that re-exposes `AppStore` slices in the legacy facade's shape — usually a `readonly` interface plus a small factory:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// store.ts (legacy facade, post-migration)
|
|
478
|
+
import { Injectable, Signal, computed, inject } from '@angular/core';
|
|
479
|
+
import { LoadingState } from '@signaltree/core';
|
|
480
|
+
import { AppStore } from './signaltree';
|
|
481
|
+
|
|
482
|
+
/** Public legacy shape — components and specs already depend on this. */
|
|
483
|
+
export interface LegacyDriverFacade {
|
|
484
|
+
readonly currentDriver: Signal<{ id: number; name: string } | null>;
|
|
485
|
+
readonly isLoading: Signal<boolean>;
|
|
486
|
+
readonly loadingState: Signal<LoadingState>;
|
|
487
|
+
loadActiveDriver(): void;
|
|
488
|
+
clearCurrentDriver(): void;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function createLegacyDriverAdapter(app: AppStore): LegacyDriverFacade {
|
|
492
|
+
const $driver = app.$.driver;
|
|
493
|
+
return {
|
|
494
|
+
currentDriver: $driver.currentDriver,
|
|
495
|
+
isLoading: $driver.load.loading, // legacy facade name → v10.3 canonical .loading
|
|
496
|
+
loadingState: computed(() => $driver.load.state()),
|
|
497
|
+
loadActiveDriver: () => {
|
|
498
|
+
app.ops.driver.loadActiveDriver$().subscribe();
|
|
499
|
+
},
|
|
500
|
+
clearCurrentDriver: () => app.ops.driver.clearCurrentDriver(),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
@Injectable({ providedIn: 'root' })
|
|
505
|
+
export class Store {
|
|
506
|
+
private readonly _app = inject(AppStore);
|
|
507
|
+
/** Legacy `store.drivers.*` — same shape as before the migration. */
|
|
508
|
+
readonly drivers: LegacyDriverFacade = createLegacyDriverAdapter(this._app);
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Adapter rules
|
|
513
|
+
|
|
514
|
+
- **Adapter functions are pure.** They take an `AppStore` instance and return the legacy interface — no DI, no `inject()`, no state.
|
|
515
|
+
- **Adapter values are computed once per `Store` instance**, not lazily on each access. Components reading `store.drivers.currentDriver` should hit the same `Signal` reference every time.
|
|
516
|
+
- **Ban legacy back-references.** The new `Ops` classes must never read from the legacy facade — only `AppStore -> Ops -> tree`. The arrow goes one way.
|
|
517
|
+
- **Move shared types into the `signaltree/` directory before rewriting the legacy facade.** State shapes and DTOs that the slice owns should live next to the new `Ops` and tree definitions, with the legacy file re-exporting them for backward compat. Otherwise the new `Ops` class will need to import the legacy facade for its types — instant circular import.
|
|
518
|
+
- **Cross-cutting `signalStoreFeature` extensions don't port as-is.** Behaviour-only features (error banners, telemetry baggage, refresh hooks) cannot be bolted onto the plain `@Injectable` adapter. Three valid options: **(a)** drop them and reintroduce as SignalTree enhancers in a follow-up (cheapest if no test exercises them); **(b)** rewrite the cross-cutting behaviour as constructor-body subscriptions on the relevant `Ops` class that read `tree.$.<domain>` and call the same downstream services; **(c)** keep the cross-cutting behaviour on the legacy ngrx store until the underlying library is migrated. Document which option you chose in a class-level comment.
|
|
519
|
+
- **Mandatory deletion deadline.** Every adapter ships with `// TODO(legacy-facade): remove by <date/release>` _and_ a tracking issue. A facade with no deletion plan becomes a permanent second store and a maintenance burden — don't ship one. Grep for the TODO before every release; if the deadline has passed, the next sprint is the deletion sprint, not "we'll get to it."
|
|
520
|
+
|
|
521
|
+
### Test impact
|
|
522
|
+
|
|
523
|
+
Existing specs that mocked `Mock<DriverStore>` keep their shape — they now mock `LegacyDriverFacade` instead. **But** the `TestBed` that constructs `Store` will instantiate `AppStore`, which needs `provideAppTreeForTesting()`. See [`testing.md`](./testing.md) for the full recipe.
|
|
524
|
+
|
|
525
|
+
## Create the tree where it's used
|
|
526
|
+
|
|
527
|
+
A SignalTree is a value, not a module. Create it wherever it logically lives — usually in a component or a service factory.
|
|
528
|
+
|
|
529
|
+
### In a component
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
import { Component } from '@angular/core';
|
|
533
|
+
import { signalTree } from '@signaltree/core';
|
|
534
|
+
|
|
535
|
+
@Component({
|
|
536
|
+
selector: 'app-counter',
|
|
537
|
+
template: `<button (click)="inc()">{{ tree.$.count() }}</button>`,
|
|
538
|
+
})
|
|
539
|
+
export class CounterComponent {
|
|
540
|
+
readonly tree = signalTree({ count: 0 });
|
|
541
|
+
|
|
542
|
+
inc() {
|
|
543
|
+
this.tree.$.count.update((n) => n + 1);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### Shared service (`providedIn: 'root'`)
|
|
549
|
+
|
|
550
|
+
When a tree is genuinely shared across routes or components (auth/identity,
|
|
551
|
+
user preferences, a reference-data cache), put it on a single
|
|
552
|
+
`providedIn: 'root'` service. The tree is private; the service exposes
|
|
553
|
+
read-only `Signal<T>` leaves plus mutator methods. This matches the external
|
|
554
|
+
shape of `@ngrx/signals`' `signalStore({ providedIn: 'root' })`, which makes
|
|
555
|
+
it the natural target when migrating.
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
import { inject, Injectable, Signal } from '@angular/core';
|
|
559
|
+
import { signalTree } from '@signaltree/core';
|
|
560
|
+
|
|
561
|
+
interface UserState {
|
|
562
|
+
name: string;
|
|
563
|
+
email: string;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
@Injectable({ providedIn: 'root' })
|
|
567
|
+
export class UserStore {
|
|
568
|
+
private readonly _tree = signalTree<UserState>({ name: '', email: '' });
|
|
569
|
+
|
|
570
|
+
// Expose leaves as read-only signals.
|
|
571
|
+
readonly name: Signal<string> = this._tree.$.name;
|
|
572
|
+
readonly email: Signal<string> = this._tree.$.email;
|
|
573
|
+
|
|
574
|
+
// Expose mutators.
|
|
575
|
+
setName(v: string): void {
|
|
576
|
+
this._tree.$.name.set(v);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
setEmail(v: string): void {
|
|
580
|
+
this._tree.$.email.set(v);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Consumers:
|
|
585
|
+
// readonly user = inject(UserStore);
|
|
586
|
+
// name = this.user.name; // Signal<string>
|
|
587
|
+
// rename() { this.user.setName('Grace'); }
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
For app-wide state, prefer the single-`AppStore`-plus-`Ops` shape above over
|
|
591
|
+
growing one service per domain.
|
|
592
|
+
|
|
593
|
+
### Alternative (rare): factory + `InjectionToken`
|
|
594
|
+
|
|
595
|
+
Factory-plus-`InjectionToken` is the older recipe. It's still valid for
|
|
596
|
+
narrow cases (e.g. a tree whose construction depends on runtime providers
|
|
597
|
+
you don't want to re-express as constructor-injected deps), but the shared
|
|
598
|
+
service above is shorter, gives you DI-friendly mocking via
|
|
599
|
+
`TestBed.overrideProvider(UserStore, ...)`, and doesn't require a separate
|
|
600
|
+
token declaration.
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
import { InjectionToken } from '@angular/core';
|
|
604
|
+
import { signalTree } from '@signaltree/core';
|
|
605
|
+
|
|
606
|
+
interface UserState {
|
|
607
|
+
name: string;
|
|
608
|
+
email: string;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export const USER_TREE = new InjectionToken<ReturnType<typeof createUserTree>>('UserTree');
|
|
612
|
+
|
|
613
|
+
export function createUserTree() {
|
|
614
|
+
const tree = signalTree<UserState>({ name: '', email: '' });
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
name: tree.$.name,
|
|
618
|
+
email: tree.$.email,
|
|
619
|
+
setName: (v: string) => tree.$.name.set(v),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function provideUserTree() {
|
|
624
|
+
return { provide: USER_TREE, useFactory: createUserTree };
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
## Reading in templates
|
|
629
|
+
|
|
630
|
+
The `$` proxy exposes signals, so Angular templates consume them directly.
|
|
631
|
+
|
|
632
|
+
```html
|
|
633
|
+
<!-- Read a leaf -->
|
|
634
|
+
<span>{{ tree.$.user.name() }}</span>
|
|
635
|
+
|
|
636
|
+
<!-- Two-way binding -->
|
|
637
|
+
<input [(ngModel)]="tree.$.fields.username" />
|
|
638
|
+
|
|
639
|
+
<!-- One-way binding -->
|
|
640
|
+
<input [value]="tree.$.fields.email()" />
|
|
641
|
+
|
|
642
|
+
<!-- Conditionals and loops use the same signal reads -->
|
|
643
|
+
@if (tree.$.load.loading()) {
|
|
644
|
+
<spinner />
|
|
645
|
+
} @for (user of tree.$.users.all(); track user.id) {
|
|
646
|
+
<user-row [user]="user" />
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
## Computed values
|
|
651
|
+
|
|
652
|
+
Use Angular's `computed()` for derived state. Read tree signals inside the callback just like any other signal.
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
import { Component, computed } from '@angular/core';
|
|
656
|
+
import { signalTree } from '@signaltree/core';
|
|
657
|
+
|
|
658
|
+
interface CartItem {
|
|
659
|
+
price: number;
|
|
660
|
+
qty: number;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
@Component({ selector: 'app-cart', template: '' })
|
|
664
|
+
export class CartComponent {
|
|
665
|
+
readonly tree = signalTree({
|
|
666
|
+
items: [] as CartItem[],
|
|
667
|
+
taxRate: 0.08,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
readonly subtotal = computed(() => this.tree.$.items().reduce((s, i) => s + i.price * i.qty, 0));
|
|
671
|
+
|
|
672
|
+
readonly total = computed(() => this.subtotal() * (1 + this.tree.$.taxRate()));
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Reach for plain Angular `computed()` whenever a derivation recomputes more than you'd like — it caches by reference and short-circuits identical inputs. SignalTree no longer ships a `memoization()` enhancer (removed in 9.0.1); for deep- or shallow-equality keying, write the comparison inside your own `computed()`.
|
|
677
|
+
|
|
678
|
+
## Effects
|
|
679
|
+
|
|
680
|
+
Use `effect()` for side effects that should react to tree changes — analytics, logging, localStorage beyond what `persistence()` covers, or imperative integrations.
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
import { Component, effect } from '@angular/core';
|
|
684
|
+
import { signalTree } from '@signaltree/core';
|
|
685
|
+
|
|
686
|
+
@Component({ selector: 'app-log', template: '' })
|
|
687
|
+
export class LogComponent {
|
|
688
|
+
readonly tree = signalTree({ count: 0 });
|
|
689
|
+
|
|
690
|
+
constructor() {
|
|
691
|
+
effect(() => {
|
|
692
|
+
const n = this.tree.$.count();
|
|
693
|
+
console.log('count changed:', n);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
Never call `.set()`, `.update()`, or any tree mutation inside `computed()`. Writes belong in `effect()` or an imperative handler.
|
|
700
|
+
|
|
701
|
+
### RxJS interop
|
|
702
|
+
|
|
703
|
+
Angular's `toObservable` / `toSignal` bridge works with tree signals exactly
|
|
704
|
+
like any other signal — no adapter needed.
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import { Component, DestroyRef, inject } from '@angular/core';
|
|
708
|
+
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
709
|
+
import type { Observable } from 'rxjs';
|
|
710
|
+
import { signalTree } from '@signaltree/core';
|
|
711
|
+
|
|
712
|
+
@Component({ selector: 'app-rx-bridge', template: '' })
|
|
713
|
+
export class RxBridgeComponent {
|
|
714
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
715
|
+
|
|
716
|
+
readonly tree = signalTree({ query: '', count: 0 });
|
|
717
|
+
|
|
718
|
+
// Signal → Observable
|
|
719
|
+
readonly query$: Observable<string> = toObservable(this.tree.$.query);
|
|
720
|
+
|
|
721
|
+
constructor() {
|
|
722
|
+
// Observable → tree (write on next).
|
|
723
|
+
this.query$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((q) => this.tree.$.count.set(q.length));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
## Bulk updates via `.update()`
|
|
729
|
+
|
|
730
|
+
For list mutations, build the new array immutably and pass it to `.update()`.
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
interface Todo {
|
|
734
|
+
id: number;
|
|
735
|
+
text: string;
|
|
736
|
+
done: boolean;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const tree = signalTree({ todos: [] as Todo[] });
|
|
740
|
+
|
|
741
|
+
// append
|
|
742
|
+
tree.$.todos.update((ts) => [...ts, { id: 3, text: 'ship it', done: false }]);
|
|
743
|
+
|
|
744
|
+
// toggle by id
|
|
745
|
+
tree.$.todos.update((ts) => ts.map((t) => (t.id === 3 ? { ...t, done: !t.done } : t)));
|
|
746
|
+
|
|
747
|
+
// remove
|
|
748
|
+
tree.$.todos.update((ts) => ts.filter((t) => t.id !== 3));
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
If mutations are hot, switch the list to `entityMap<Todo, number>()` for O(1) CRUD — see [`core.md`](core.md).
|
|
752
|
+
|
|
753
|
+
## `entityMap` CRUD
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
757
|
+
|
|
758
|
+
interface User {
|
|
759
|
+
id: number;
|
|
760
|
+
name: string;
|
|
761
|
+
active: boolean;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const store = signalTree({ users: entityMap<User, number>() });
|
|
765
|
+
|
|
766
|
+
store.$.users.setAll([
|
|
767
|
+
{ id: 1, name: 'Ada', active: true },
|
|
768
|
+
{ id: 2, name: 'Grace', active: false },
|
|
769
|
+
]);
|
|
770
|
+
store.$.users.addOne({ id: 3, name: 'Hedy', active: true });
|
|
771
|
+
store.$.users.upsertOne({ id: 1, name: 'Ada L.', active: true });
|
|
772
|
+
store.$.users.removeOne(2);
|
|
773
|
+
|
|
774
|
+
const all = store.$.users.all(); // User[]
|
|
775
|
+
const adaNode = store.$.users.byId(1); // EntityNode<User> | undefined — invoke: adaNode?.()
|
|
776
|
+
const ada = adaSig ? adaSig() : null;
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
## Separate-file derived with `derivedFrom`
|
|
780
|
+
|
|
781
|
+
When a derived block doesn't belong inline in the component, declare it in a sibling file and keep full type inference:
|
|
782
|
+
|
|
783
|
+
```ts
|
|
784
|
+
// derived.ts
|
|
785
|
+
import { derivedFrom, type SignalTree } from '@signaltree/core';
|
|
786
|
+
|
|
787
|
+
interface AppState {
|
|
788
|
+
counter: number;
|
|
789
|
+
}
|
|
790
|
+
type AppTree = SignalTree<AppState>;
|
|
791
|
+
|
|
792
|
+
export const appDerived = derivedFrom<AppTree>()(($) => ({
|
|
793
|
+
doubled: $.counter() * 2,
|
|
794
|
+
}));
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Consume it by passing it as the second argument to `signalTree()`:
|
|
798
|
+
|
|
799
|
+
```ts skip
|
|
800
|
+
// store.ts — conceptual shape (skipped by the skill lint because the
|
|
801
|
+
// current public types erase TDerived in the overload return type, so
|
|
802
|
+
// `tree.$.doubled` is not surfaced on the builder even though it works
|
|
803
|
+
// at runtime).
|
|
804
|
+
import { signalTree } from '@signaltree/core';
|
|
805
|
+
import { appDerived } from './derived';
|
|
806
|
+
|
|
807
|
+
export const tree = signalTree({ counter: 0 }, appDerived);
|
|
808
|
+
tree.$.counter(); // number
|
|
809
|
+
tree.$.doubled(); // number (from derived, available at runtime)
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
Prefer Angular's `computed()` inline (declared next to the component field
|
|
813
|
+
that uses it) for small derivations. Reach for `derivedFrom` when the
|
|
814
|
+
derivation set lives in its own file; use it alongside `computed()` inside
|
|
815
|
+
the factory rather than relying on the `signalTree(initial, factory)`
|
|
816
|
+
overload to expose typed derived props.
|
|
817
|
+
|
|
818
|
+
## Composing enhancers: a production-shaped tree
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
import { signalTree, batching, devTools, persistence } from '@signaltree/core';
|
|
822
|
+
|
|
823
|
+
interface AppState {
|
|
824
|
+
user: { name: string; email: string };
|
|
825
|
+
ui: { theme: 'light' | 'dark'; sidebarOpen: boolean };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export const appTree = signalTree<AppState>({
|
|
829
|
+
user: { name: '', email: '' },
|
|
830
|
+
ui: { theme: 'light', sidebarOpen: false },
|
|
831
|
+
})
|
|
832
|
+
.with(batching({ enabled: true, notificationDelayMs: 0 }))
|
|
833
|
+
.with(devTools({ treeName: 'AppTree' }))
|
|
834
|
+
.with(
|
|
835
|
+
persistence({
|
|
836
|
+
key: 'app.v1',
|
|
837
|
+
autoSave: true,
|
|
838
|
+
autoLoad: true,
|
|
839
|
+
debounceMs: 300,
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
## Exposing a read-only API
|
|
845
|
+
|
|
846
|
+
If your tree lives inside a service factory, use a TypeScript interface to expose `Signal<T>` (read-only) to consumers while the implementation keeps the underlying `WritableSignal<T>`.
|
|
847
|
+
|
|
848
|
+
```ts
|
|
849
|
+
import { Signal } from '@angular/core';
|
|
850
|
+
import { signalTree } from '@signaltree/core';
|
|
851
|
+
|
|
852
|
+
export interface CounterApi {
|
|
853
|
+
readonly count: Signal<number>;
|
|
854
|
+
inc(): void;
|
|
855
|
+
reset(): void;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export function createCounter(): CounterApi {
|
|
859
|
+
const tree = signalTree({ count: 0 });
|
|
860
|
+
return {
|
|
861
|
+
count: tree.$.count,
|
|
862
|
+
inc: () => tree.$.count.update((n) => n + 1),
|
|
863
|
+
reset: () => tree.$.count.set(0),
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
Consumers get read-only signals; only the factory can mutate.
|
|
869
|
+
|
|
870
|
+
## Porting reusable store features (`createEnhancer`)
|
|
871
|
+
|
|
872
|
+
Reusable cross-cutting behavior — dismissable banners, toast queues, optimistic
|
|
873
|
+
locking, you name it — lives in an **enhancer** rather than in a mixin or a
|
|
874
|
+
base class. `createEnhancer` attaches metadata (for dependency ordering and
|
|
875
|
+
DevTools) and types the surface the enhancer adds to the tree.
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
import { signalTree } from '@signaltree/core';
|
|
879
|
+
import { createEnhancer } from '@signaltree/core/authoring';
|
|
880
|
+
|
|
881
|
+
// The extra surface this enhancer adds to the tree.
|
|
882
|
+
interface BannerApi {
|
|
883
|
+
dismissBanner: () => void;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export const withDismissableBanner = createEnhancer<BannerApi>(
|
|
887
|
+
{
|
|
888
|
+
name: 'dismissable-banner',
|
|
889
|
+
provides: ['banner'],
|
|
890
|
+
// requires: ['batching'], // optional — forces ordering
|
|
891
|
+
},
|
|
892
|
+
(tree) => {
|
|
893
|
+
const enhanced = tree as typeof tree & BannerApi;
|
|
894
|
+
// The enhancer is applied to a tree that owns a `banner.visible` leaf.
|
|
895
|
+
const banner = (
|
|
896
|
+
tree.$ as unknown as {
|
|
897
|
+
banner: { visible: { set: (v: boolean) => void } };
|
|
898
|
+
}
|
|
899
|
+
).banner;
|
|
900
|
+
enhanced.dismissBanner = () => banner.visible.set(false);
|
|
901
|
+
return enhanced;
|
|
902
|
+
}
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
// `createEnhancer` is typed against `ISignalTree<any>`, so cast at the call
|
|
906
|
+
// site to bind it to your concrete tree shape while preserving the `BannerApi`
|
|
907
|
+
// surface the enhancer adds.
|
|
908
|
+
import type { ISignalTree } from '@signaltree/core';
|
|
909
|
+
|
|
910
|
+
type BannerState = { banner: { visible: boolean } };
|
|
911
|
+
const appTree = signalTree<BannerState>({ banner: { visible: true } }).with(withDismissableBanner as unknown as (tree: ISignalTree<BannerState>) => ISignalTree<BannerState> & BannerApi);
|
|
912
|
+
|
|
913
|
+
appTree.$.banner.visible(); // true
|
|
914
|
+
appTree.dismissBanner(); // available on the builder
|
|
915
|
+
appTree.$.banner.visible(); // false
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
For authoring larger enhancers (custom markers, scheduler hooks, async
|
|
919
|
+
middleware), see [`../../guides/custom-markers-enhancers.md`](../../guides/custom-markers-enhancers.md).
|
|
920
|
+
|
|
921
|
+
## Porting `signalStoreFeature` patterns
|
|
922
|
+
|
|
923
|
+
`@ngrx/signals`' `signalStoreFeature` is used for two distinct jobs in practice.
|
|
924
|
+
SignalTree has a dedicated answer for each:
|
|
925
|
+
|
|
926
|
+
| What the feature does | SignalTree equivalent |
|
|
927
|
+
| --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
928
|
+
| Adds reusable **state shape + reactive behaviour** to a slice (`withLoadingState`, `withSavingState`, `withEntities`) | **Built-in or custom marker** — the behaviour lives in the tree itself |
|
|
929
|
+
| Adds **cross-cutting tree-level behaviour** needing DI (`withErrorBanners`, `withTelemetryBaggage`) | `createEnhancer` (see above) |
|
|
930
|
+
| **Side-effects on init** (subscriptions, `effect()`) | Constructor body of the Ops class |
|
|
931
|
+
|
|
932
|
+
### Built-in markers replace `withLoadingState` and `withEntities`
|
|
933
|
+
|
|
934
|
+
`withLoadingState` adds loading/error state to a store. SignalTree's `status()`
|
|
935
|
+
marker does the same job — declare it once in the state shape and the reactive
|
|
936
|
+
methods appear on that node automatically:
|
|
937
|
+
|
|
938
|
+
```ts
|
|
939
|
+
import { entityMap, signalTree, status } from '@signaltree/core';
|
|
940
|
+
|
|
941
|
+
interface Ticket {
|
|
942
|
+
id: number;
|
|
943
|
+
title: string;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const tree = signalTree({
|
|
947
|
+
tickets: {
|
|
948
|
+
entities: entityMap<Ticket>(), // replaces withEntities
|
|
949
|
+
loading: status(), // replaces withLoadingState
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// status() attaches setters, boolean readers, and raw state/error to the node:
|
|
954
|
+
tree.$.tickets.loading.setLoading();
|
|
955
|
+
tree.$.tickets.loading.setLoaded();
|
|
956
|
+
tree.$.tickets.loading.setError(new Error('network failure'));
|
|
957
|
+
tree.$.tickets.loading.setNotLoaded(); // reset to initial state
|
|
958
|
+
|
|
959
|
+
// Boolean signals — v10.3 canonical (bare names). Prefer these over .state() string comparisons.
|
|
960
|
+
tree.$.tickets.loading.loading(); // boolean
|
|
961
|
+
tree.$.tickets.loading.loaded(); // boolean
|
|
962
|
+
tree.$.tickets.loading.hasError(); // boolean
|
|
963
|
+
tree.$.tickets.loading.notLoaded(); // boolean
|
|
964
|
+
// (The is-prefix aliases .isLoading/.isLoaded/.isError/.isNotLoaded were removed in v11.)
|
|
965
|
+
|
|
966
|
+
const err = tree.$.tickets.loading.error(); // Error | null
|
|
967
|
+
|
|
968
|
+
// If you must compare raw state, import and use the LoadingState enum:
|
|
969
|
+
// import { LoadingState } from '@signaltree/core';
|
|
970
|
+
// tree.$.tickets.loading.state() === LoadingState.Loading ✓
|
|
971
|
+
// tree.$.tickets.loading.state() === 'loading' ✗ TypeScript error
|
|
972
|
+
|
|
973
|
+
// entityMap() attaches upsertOne / setAll / removeOne / byId / all / clear:
|
|
974
|
+
tree.$.tickets.entities.setAll([{ id: 1, title: 'Haul A' }], { selectId: (t) => t.id });
|
|
975
|
+
const all = tree.$.tickets.entities.all(); // Signal<Ticket[]>
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
Use `status()` anywhere you previously reached for `withLoadingState` or a
|
|
979
|
+
hand-rolled `{ loading, error }` slice. The marker is reusable across every
|
|
980
|
+
domain in the tree — declare it in each domain's state factory, not once globally.
|
|
981
|
+
|
|
982
|
+
#### When NOT to use `status()`
|
|
983
|
+
|
|
984
|
+
`status()` is opinionated about two things: (1) the loading-state values it stores (its own internal enum string values such as `'LOADED'`), and (2) the error channel — `setError(error)` is a single slot that callers typically end up stringifying.
|
|
985
|
+
|
|
986
|
+
Reach for a plain slice instead when **either** is true:
|
|
987
|
+
|
|
988
|
+
- **Your codebase already has a `LoadingState` enum** with different string values (e.g. `'Loaded'` vs `status()`'s `'LOADED'`). Importing both names invites a silent runtime mismatch that only fails in tests that compare on `state()`.
|
|
989
|
+
- **Your codebase has a rich error model** (e.g. a `NotifyErrorModel` with `message`, `errorMessage`, `correlationId`, etc.) and you need to round-trip the full object, not a stringified copy.
|
|
990
|
+
|
|
991
|
+
In either case, write a tiny slice factory that returns the exact types your codebase already uses:
|
|
992
|
+
|
|
993
|
+
```ts skip
|
|
994
|
+
// loading-slice.ts
|
|
995
|
+
import { LoadingState, NotifyErrorModel, Nullable } from '@models';
|
|
996
|
+
|
|
997
|
+
export function loadingSlice() {
|
|
998
|
+
return {
|
|
999
|
+
state: LoadingState.NotLoaded as LoadingState,
|
|
1000
|
+
error: null as Nullable<NotifyErrorModel>,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
Then consume it like any other slice — `tree.$.tickets.loading.state()`, `tree.$.tickets.loading.state.set(LoadingState.Loading)`, `tree.$.tickets.loading.error.set(captureError(err))`. You lose the `setLoading()` / `loaded()` ergonomic methods, but you keep your existing enum and error type — usually the right trade in established codebases.
|
|
1006
|
+
|
|
1007
|
+
### Custom markers for novel reusable state behaviour
|
|
1008
|
+
|
|
1009
|
+
When no built-in marker fits your need — e.g. a `savingState()` marker that
|
|
1010
|
+
tracks optimistic writes, or a `pagination()` marker that manages page/pageSize —
|
|
1011
|
+
author a **custom marker** with `registerMarkerProcessor`. The marker attaches
|
|
1012
|
+
reactive methods to any node it is applied to, exactly like `status()` or
|
|
1013
|
+
`entityMap()`.
|
|
1014
|
+
|
|
1015
|
+
```ts skip
|
|
1016
|
+
// saving-state.marker.ts
|
|
1017
|
+
import { registerMarkerProcessor } from '@signaltree/core/authoring';
|
|
1018
|
+
import { signal, computed } from '@angular/core';
|
|
1019
|
+
|
|
1020
|
+
export const savingState = () => Symbol('savingState');
|
|
1021
|
+
|
|
1022
|
+
registerMarkerProcessor(savingState, (_initialValue, node) => {
|
|
1023
|
+
const _isSaving = signal(false);
|
|
1024
|
+
const _isSaved = signal(false);
|
|
1025
|
+
|
|
1026
|
+
Object.assign(node, {
|
|
1027
|
+
isSaving: computed(() => _isSaving()),
|
|
1028
|
+
isSaved: computed(() => _isSaved()),
|
|
1029
|
+
setSaving: () => {
|
|
1030
|
+
_isSaving.set(true);
|
|
1031
|
+
_isSaved.set(false);
|
|
1032
|
+
},
|
|
1033
|
+
setSaved: () => {
|
|
1034
|
+
_isSaving.set(false);
|
|
1035
|
+
_isSaved.set(true);
|
|
1036
|
+
},
|
|
1037
|
+
resetSave: () => {
|
|
1038
|
+
_isSaving.set(false);
|
|
1039
|
+
_isSaved.set(false);
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// Usage in state factory:
|
|
1045
|
+
// { ticket: { data: null, saving: savingState() } }
|
|
1046
|
+
// → tree.$.ticket.saving.setSaving() / .setSaved() / .isSaving() etc.
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
> `registerMarkerProcessor` must be called **before** any `signalTree()` call
|
|
1050
|
+
> that uses the marker. Call it once at module load time.
|
|
1051
|
+
|
|
1052
|
+
The `ts skip` fence above is intentional — the `registerMarkerProcessor` callback
|
|
1053
|
+
signature is low-level; verify the exact API against the core package types before
|
|
1054
|
+
shipping a custom marker.
|
|
1055
|
+
|
|
1056
|
+
## Replacing `rxMethod` (from `@ngrx/signals`)
|
|
1057
|
+
|
|
1058
|
+
`@ngrx/signals` ships `rxMethod` to wire a method to an internally-owned
|
|
1059
|
+
subscription. **SignalTree does not ship a `rxMethod` primitive** — the
|
|
1060
|
+
SignalTree-native answer is to put async behavior at the tree path it
|
|
1061
|
+
describes. Map NgRx `rxMethod` to one of these two options:
|
|
1062
|
+
|
|
1063
|
+
### 1. `asyncSource` / `asyncQuery` markers (canonical SignalTree pattern)
|
|
1064
|
+
|
|
1065
|
+
For the vast majority of cases — load-and-expose or input-driven query — the
|
|
1066
|
+
markers eliminate the need for an Ops class subscription entirely. The marker
|
|
1067
|
+
materializer wires up `data`/`loading`/`error`/`refresh` automatically.
|
|
1068
|
+
|
|
1069
|
+
```ts
|
|
1070
|
+
import { signalTree, asyncSource, asyncQuery } from '@signaltree/core';
|
|
1071
|
+
|
|
1072
|
+
const store = signalTree({
|
|
1073
|
+
// ngrx style: rxMethod<void>(...) → load triggered by .loadUsers()
|
|
1074
|
+
// signaltree: asyncSource at the path the data lives at
|
|
1075
|
+
users: asyncSource<User[]>({
|
|
1076
|
+
initial: [],
|
|
1077
|
+
load: () => api.list$(),
|
|
1078
|
+
}),
|
|
1079
|
+
|
|
1080
|
+
// ngrx style: rxMethod<string>(input$ => input$.pipe(debounceTime(300), switchMap(...)))
|
|
1081
|
+
// signaltree: asyncQuery — debounce + dedup + switchMap built in
|
|
1082
|
+
search: asyncQuery<string, User[]>({
|
|
1083
|
+
initialResult: [],
|
|
1084
|
+
debounce: 300,
|
|
1085
|
+
filter: (q) => q.length > 0,
|
|
1086
|
+
query: (q) => api.search$(q),
|
|
1087
|
+
}),
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
store.$.users.refresh(); // load-and-expose
|
|
1091
|
+
store.$.search.input.set('alice'); // drives debounced pipeline
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
See [`core.md` § asyncSource](core.md#asyncsourcetconfig) and [§ asyncQuery](core.md#asyncquerytinput-tresultconfig) for full API.
|
|
1095
|
+
|
|
1096
|
+
### 2. Plain Observable in an Ops class
|
|
1097
|
+
|
|
1098
|
+
When neither marker fits — e.g., complex multi-step orchestration where the
|
|
1099
|
+
caller needs explicit subscription control — write an Ops method that returns
|
|
1100
|
+
`Observable<void>`. Two sub-flavors based on who owns the subscription:
|
|
1101
|
+
|
|
1102
|
+
#### 2a. Store-owned subscription (fire-and-forget effect)
|
|
1103
|
+
|
|
1104
|
+
When the Ops class should drive the subscription itself — e.g. a reactive
|
|
1105
|
+
debounced search that should run as long as the app is alive — subscribe
|
|
1106
|
+
inside the constructor using `takeUntilDestroyed(inject(DestroyRef))`:
|
|
1107
|
+
|
|
1108
|
+
```ts
|
|
1109
|
+
import { inject, Injectable, DestroyRef, InjectionToken } from '@angular/core';
|
|
1110
|
+
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
1111
|
+
import { debounceTime, switchMap, tap } from 'rxjs/operators';
|
|
1112
|
+
import type { Observable } from 'rxjs';
|
|
1113
|
+
import { signalTree } from '@signaltree/core';
|
|
1114
|
+
|
|
1115
|
+
interface AppState {
|
|
1116
|
+
search: { query: string; results: string[] };
|
|
1117
|
+
}
|
|
1118
|
+
declare const APP_TREE: InjectionToken<ReturnType<typeof signalTree<AppState>>>;
|
|
1119
|
+
interface SearchApi {
|
|
1120
|
+
query$(q: string): Observable<string[]>;
|
|
1121
|
+
}
|
|
1122
|
+
declare const SEARCH_API: InjectionToken<SearchApi>;
|
|
1123
|
+
|
|
1124
|
+
@Injectable({ providedIn: 'root' })
|
|
1125
|
+
export class SearchOps {
|
|
1126
|
+
private readonly _tree = inject(APP_TREE);
|
|
1127
|
+
private readonly _$ = this._tree.$;
|
|
1128
|
+
private readonly _api = inject(SEARCH_API);
|
|
1129
|
+
|
|
1130
|
+
constructor() {
|
|
1131
|
+
toObservable(this._$.search.query)
|
|
1132
|
+
.pipe(
|
|
1133
|
+
debounceTime(250),
|
|
1134
|
+
switchMap((q) => this._api.query$(q)),
|
|
1135
|
+
tap((results) => this._$.search.results.set(results)),
|
|
1136
|
+
takeUntilDestroyed(inject(DestroyRef))
|
|
1137
|
+
)
|
|
1138
|
+
.subscribe();
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
#### 2b. Caller-owned observable return
|
|
1144
|
+
|
|
1145
|
+
When callers should decide when/whether to subscribe (HTTP loads, one-shot
|
|
1146
|
+
actions with router-level cancellation), return an `Observable`:
|
|
1147
|
+
|
|
1148
|
+
```ts
|
|
1149
|
+
import { inject, Injectable, InjectionToken } from '@angular/core';
|
|
1150
|
+
import type { Observable } from 'rxjs';
|
|
1151
|
+
import { map, tap } from 'rxjs/operators';
|
|
1152
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
1153
|
+
|
|
1154
|
+
interface Ticket {
|
|
1155
|
+
id: number;
|
|
1156
|
+
title: string;
|
|
1157
|
+
}
|
|
1158
|
+
interface AppState {
|
|
1159
|
+
tickets: {
|
|
1160
|
+
entities: ReturnType<typeof entityMap<Ticket, number>>;
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
declare const APP_TREE: InjectionToken<ReturnType<typeof signalTree<AppState>>>;
|
|
1164
|
+
interface TicketApi {
|
|
1165
|
+
list$(): Observable<Ticket[]>;
|
|
1166
|
+
}
|
|
1167
|
+
declare const TICKET_API: InjectionToken<TicketApi>;
|
|
1168
|
+
|
|
1169
|
+
@Injectable({ providedIn: 'root' })
|
|
1170
|
+
export class TicketOps {
|
|
1171
|
+
private readonly _tree = inject(APP_TREE);
|
|
1172
|
+
private readonly _api = inject(TICKET_API);
|
|
1173
|
+
|
|
1174
|
+
load$(): Observable<void> {
|
|
1175
|
+
return this._api.list$().pipe(
|
|
1176
|
+
tap((items) =>
|
|
1177
|
+
this._tree.$.tickets.entities.setAll(items, {
|
|
1178
|
+
selectId: (t) => t.id,
|
|
1179
|
+
})
|
|
1180
|
+
),
|
|
1181
|
+
map(() => void 0)
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Component:
|
|
1187
|
+
// load() { this.store.ops.tickets.load$().subscribe(); }
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
Rule of thumb: if the consumer cares about completion or cancellation, return
|
|
1191
|
+
`Observable<T>`. Otherwise, own the subscription in the Ops class with
|
|
1192
|
+
`takeUntilDestroyed`.
|
|
1193
|
+
|
|
1194
|
+
## Where does `.with(enhancer)` go in a class wrapper?
|
|
1195
|
+
|
|
1196
|
+
Two placement choices, driven by whether the enhancer config is static or
|
|
1197
|
+
sourced from DI.
|
|
1198
|
+
|
|
1199
|
+
- **Static config → field initializer.** Simpler, narrower type, and the tree
|
|
1200
|
+
is ready before the constructor body runs.
|
|
1201
|
+
|
|
1202
|
+
```ts
|
|
1203
|
+
import { Injectable } from '@angular/core';
|
|
1204
|
+
import { signalTree, batching } from '@signaltree/core';
|
|
1205
|
+
|
|
1206
|
+
@Injectable({ providedIn: 'root' })
|
|
1207
|
+
export class UiStore {
|
|
1208
|
+
// All config known at class-declaration time → field initializer.
|
|
1209
|
+
private readonly _tree = signalTree({ theme: 'light' as const }).with(batching());
|
|
1210
|
+
|
|
1211
|
+
readonly theme = this._tree.$.theme;
|
|
1212
|
+
}
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
- **DI-sourced config → constructor body.** Use this when the enhancer config
|
|
1216
|
+
depends on an `inject()`ed value (a storage adapter, a feature-flag signal, a
|
|
1217
|
+
logger).
|
|
1218
|
+
|
|
1219
|
+
```ts
|
|
1220
|
+
import { inject, Injectable, InjectionToken } from '@angular/core';
|
|
1221
|
+
import { signalTree, persistence } from '@signaltree/core';
|
|
1222
|
+
|
|
1223
|
+
interface AppConfig {
|
|
1224
|
+
storageKey: string;
|
|
1225
|
+
}
|
|
1226
|
+
const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
|
|
1227
|
+
|
|
1228
|
+
@Injectable({ providedIn: 'root' })
|
|
1229
|
+
export class PrefsStore {
|
|
1230
|
+
private readonly _cfg = inject(APP_CONFIG);
|
|
1231
|
+
// Declare without enhancers, then attach in the constructor so we can
|
|
1232
|
+
// reach injected dependencies.
|
|
1233
|
+
private readonly _tree = signalTree({ theme: 'light' as const });
|
|
1234
|
+
|
|
1235
|
+
readonly theme = this._tree.$.theme;
|
|
1236
|
+
|
|
1237
|
+
constructor() {
|
|
1238
|
+
this._tree.with(
|
|
1239
|
+
persistence({
|
|
1240
|
+
key: this._cfg.storageKey,
|
|
1241
|
+
autoSave: true,
|
|
1242
|
+
autoLoad: true,
|
|
1243
|
+
})
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
Keep the field-initializer form whenever you can — less ceremony, fewer places
|
|
1250
|
+
for ordering bugs.
|