@dsivd/prestations-ng 19.2.0-beta.11 → 19.2.0-beta.13
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/CHANGELOG.md +5 -0
- package/CONTRIBUTING.md +1 -1
- package/GOOD_PRACTICES.md +443 -396
- package/INTRODUCTION_ANGULAR_SIGNALS.md +150 -150
- package/UPGRADING_V19.md +50 -5
- package/fesm2022/dsivd-prestations-ng.mjs +4 -1
- package/fesm2022/dsivd-prestations-ng.mjs.map +1 -1
- package/package.json +1 -1
- package/dsivd-prestations-ng-19.2.0-beta.11.tgz +0 -0
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
# Introduction
|
|
1
|
+
# Introduction to Angular Signals
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Goal**: a solid understanding of `signal()`, `computed()`, `effect()`, `input()`/`model()` and `linkedSignal()`, as well as the reflexes to avoid the most frequent pitfalls (mutation vs reassignment, over-tracking in `effect()`).
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Session outline
|
|
8
8
|
|
|
9
|
-
| # |
|
|
10
|
-
| --- |
|
|
11
|
-
| 1. |
|
|
12
|
-
| 2. |
|
|
13
|
-
| 3. | `input()`
|
|
14
|
-
| 4. | `linkedSignal()
|
|
15
|
-
| 5. | `untracked()
|
|
16
|
-
| 6. | RxJS interop
|
|
17
|
-
| 7. |
|
|
9
|
+
| # | Block |
|
|
10
|
+
| --- | ------------------------------------------------------------- |
|
|
11
|
+
| 1. | Why signals? |
|
|
12
|
+
| 2. | The 3 primitives: `signal()`, `computed()`, `effect()` |
|
|
13
|
+
| 3. | `input()` and `model()`: the new contract of components |
|
|
14
|
+
| 4. | `linkedSignal()`: local state that resynchronizes |
|
|
15
|
+
| 5. | `untracked()`: controlling the dependencies of an `effect()` |
|
|
16
|
+
| 6. | RxJS interop: signals **and** RxJS, not one against the other |
|
|
17
|
+
| 7. | Pitfall #1: mutation vs reassignment |
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
## 1.
|
|
21
|
+
## 1. Why signals?
|
|
22
22
|
|
|
23
|
-
**
|
|
23
|
+
**Before**: Angular detects changes by (potentially) re-running **the whole component tree** on every asynchronous event intercepted by **Zone.js** (click, timer, XHR...). Historically effective, but costly and hard to predict at scale.
|
|
24
24
|
|
|
25
|
-
**
|
|
25
|
+
**With signals**: each reactive value **explicitly declares** what depends on it. Angular only recomputes/re-renders what has actually changed. It's a **fine-grained** and **declarative** model.
|
|
26
26
|
|
|
27
|
-
> 🎤
|
|
27
|
+
> 🎤 we no longer "trigger" change detection, we **describe a data flow**. The framework takes care of propagating it.
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
-
## 2.
|
|
31
|
+
## 2. The 3 basic primitives
|
|
32
32
|
|
|
33
|
-
### `signal()` —
|
|
33
|
+
### `signal()` — a writable reactive value
|
|
34
34
|
|
|
35
35
|
```ts
|
|
36
36
|
readonly count = signal(0);
|
|
37
37
|
|
|
38
38
|
increment(): void {
|
|
39
39
|
this.count.update(v => v + 1);
|
|
40
|
-
//
|
|
40
|
+
// or, to replace the value completely:
|
|
41
41
|
this.count.set(5);
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
⚠️ **
|
|
45
|
+
⚠️ **there is no `.mutate()`**. It was in the initial proposal, and removed before the stable release. You never modify the value in place — you always go through `.set()` / `.update()` with a new reference (covered in detail in block 7).
|
|
46
46
|
|
|
47
|
-
### `computed()` —
|
|
47
|
+
### `computed()` — a derived, read-only value
|
|
48
48
|
|
|
49
49
|
```ts
|
|
50
50
|
readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
- Pure
|
|
54
|
-
-
|
|
55
|
-
-
|
|
53
|
+
- Pure and synchronous, never a `.set()` inside
|
|
54
|
+
- Lazy and memoized: only recomputed when a dependency changes
|
|
55
|
+
- Dependencies are detected **automatically** — no need to declare them
|
|
56
56
|
|
|
57
|
-
### `effect()` —
|
|
57
|
+
### `effect()` — a side effect
|
|
58
58
|
|
|
59
59
|
```ts
|
|
60
60
|
effect(() => {
|
|
@@ -62,17 +62,17 @@ effect(() => {
|
|
|
62
62
|
});
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
- **
|
|
65
|
+
- Runs at least once at initialization
|
|
66
|
+
- Cleans itself up automatically when the component is destroyed
|
|
67
|
+
- **Every signal read synchronously in its body becomes a dependency** — including the ones you didn't want to "trigger" it (covered in block 5)
|
|
68
68
|
|
|
69
|
-
**
|
|
69
|
+
**Concrete example — the three primitives together:**
|
|
70
70
|
|
|
71
71
|
```ts
|
|
72
72
|
@Component({
|
|
73
73
|
selector: 'app-counter',
|
|
74
74
|
template: `
|
|
75
|
-
<p>Count
|
|
75
|
+
<p>Count: {{ count() }} ({{ isEven() ? 'even' : 'odd' }})</p>
|
|
76
76
|
<button (click)="increment()">+1</button>
|
|
77
77
|
`,
|
|
78
78
|
})
|
|
@@ -81,7 +81,7 @@ export class CounterComponent {
|
|
|
81
81
|
readonly isEven = computed(() => this.count() % 2 === 0);
|
|
82
82
|
|
|
83
83
|
constructor() {
|
|
84
|
-
//
|
|
84
|
+
// runs at init, then on every change of `count`
|
|
85
85
|
effect(() => console.log(`count = ${this.count()}`));
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -91,68 +91,68 @@ export class CounterComponent {
|
|
|
91
91
|
}
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
Note: `isEven` is only recomputed when `count` changes (memoization), and the template only re-renders the affected part — no re-render of the whole component.
|
|
95
95
|
|
|
96
|
-
### Mental model
|
|
96
|
+
### Mental model to give the team
|
|
97
97
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
98
|
+
| I want to... | I use... |
|
|
99
|
+
| -------------------------------------------------- | ------------ |
|
|
100
|
+
| Transform / derive data | `computed()` |
|
|
101
|
+
| Synchronize with the DOM, an external service, log | `effect()` |
|
|
102
102
|
|
|
103
|
-
🚩 **
|
|
103
|
+
🚩 **The red flag to learn to spot**: an `effect()` that calls `.set()` on another signal is almost always a disguised `computed()`.
|
|
104
104
|
|
|
105
105
|
```ts
|
|
106
|
-
// 🔴
|
|
106
|
+
// 🔴 To fix as soon as seen in review
|
|
107
107
|
effect(() => {
|
|
108
108
|
this.minYear.set(this.minDate()?.[0] ?? dayjs().year() - 100);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
// 🟢
|
|
111
|
+
// 🟢 What it really is
|
|
112
112
|
readonly minYear = computed(() => this.minDate()?.[0] ?? dayjs().year() - 100);
|
|
113
113
|
```
|
|
114
114
|
|
|
115
115
|
---
|
|
116
116
|
|
|
117
|
-
## 3. `input()`
|
|
117
|
+
## 3. `input()` and `model()`: the new contract of components
|
|
118
118
|
|
|
119
|
-
### `input()`
|
|
119
|
+
### `input()` replaces `@Input()` (parent → child binding, one direction)
|
|
120
120
|
|
|
121
121
|
```ts
|
|
122
|
-
//
|
|
122
|
+
// Before
|
|
123
123
|
@Input() closeable = true;
|
|
124
124
|
|
|
125
|
-
//
|
|
125
|
+
// After
|
|
126
126
|
readonly closeable = input(true);
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
-
### `model()`
|
|
129
|
+
### `model()` replaces the `@Input()` + `@Output()` pair (two-way binding)
|
|
130
130
|
|
|
131
131
|
```ts
|
|
132
|
-
//
|
|
132
|
+
// Before
|
|
133
133
|
@Input() isVisible = false;
|
|
134
134
|
@Output() isVisibleChange = new EventEmitter<boolean>();
|
|
135
135
|
|
|
136
|
-
//
|
|
136
|
+
// After
|
|
137
137
|
readonly isVisible = model(false);
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
```html
|
|
141
|
-
<!--
|
|
141
|
+
<!-- both ways -->
|
|
142
142
|
<my-component [(isVisible)]="open" />
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
|
|
|
146
|
-
|
|
|
147
|
-
|
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
|
|
|
145
|
+
| Scenario | Tool |
|
|
146
|
+
| ----------------------------------------------------------------- | ---------------- |
|
|
147
|
+
| The parent writes, the child only reads | `input()` |
|
|
148
|
+
| The parent writes, the child writes back, the parent reads | `model()` |
|
|
149
|
+
| The parent writes, the child writes back, the parent doesn't read | `linkedSignal()` |
|
|
150
|
+
| Derived from other inputs | `computed()` |
|
|
151
151
|
|
|
152
|
-
**
|
|
152
|
+
**Concrete example — before/after on a modal-like component:**
|
|
153
153
|
|
|
154
154
|
```ts
|
|
155
|
-
// 🔴
|
|
155
|
+
// 🔴 Before: @Input()/@Output() + manual synchronization
|
|
156
156
|
@Input() isVisible = false;
|
|
157
157
|
@Output() isVisibleChange = new EventEmitter<boolean>();
|
|
158
158
|
|
|
@@ -163,7 +163,7 @@ close(): void {
|
|
|
163
163
|
```
|
|
164
164
|
|
|
165
165
|
```ts
|
|
166
|
-
// 🟢
|
|
166
|
+
// 🟢 After: model()
|
|
167
167
|
readonly isVisible = model(false);
|
|
168
168
|
|
|
169
169
|
close(): void {
|
|
@@ -172,26 +172,26 @@ close(): void {
|
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
```html
|
|
175
|
-
<!--
|
|
175
|
+
<!-- On the parent side: no synchronization code to write -->
|
|
176
176
|
<app-modal [(isVisible)]="showModal" />
|
|
177
177
|
```
|
|
178
178
|
|
|
179
179
|
---
|
|
180
180
|
|
|
181
|
-
## 4. `linkedSignal()
|
|
181
|
+
## 4. `linkedSignal()`: local state that resynchronizes
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
The missing link between `computed()` (read-only) and `signal()` (no automatic synchronization): a value that is **writable locally**, but that **resets** when a source changes.
|
|
184
184
|
|
|
185
185
|
```ts
|
|
186
186
|
readonly options = input<string[]>([]);
|
|
187
187
|
readonly selectedOption = linkedSignal(() => this.options()[0]);
|
|
188
188
|
|
|
189
|
-
//
|
|
189
|
+
// The user can still write to it...
|
|
190
190
|
this.selectedOption.set('other value');
|
|
191
|
-
// ...
|
|
191
|
+
// ...until `options` changes, in which case it resets.
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
🚩 **Red flag
|
|
194
|
+
🚩 **Red flag to learn**: an `effect()` that resets a plain `signal()` when another value changes is a `linkedSignal()` in disguise.
|
|
195
195
|
|
|
196
196
|
```ts
|
|
197
197
|
// 🔴
|
|
@@ -202,7 +202,7 @@ effect(() => this.selectedOption.set(this.options()[0]));
|
|
|
202
202
|
readonly selectedOption = linkedSignal(() => this.options()[0]);
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
-
**
|
|
205
|
+
**Concrete example — a `select` whose options come from the parent:**
|
|
206
206
|
|
|
207
207
|
```ts
|
|
208
208
|
@Component({
|
|
@@ -221,100 +221,100 @@ readonly selectedOption = linkedSignal(() => this.options()[0]);
|
|
|
221
221
|
export class BranchSelectComponent {
|
|
222
222
|
readonly branches = input<string[]>([]);
|
|
223
223
|
|
|
224
|
-
//
|
|
225
|
-
//
|
|
224
|
+
// resets to the first element on every change of `branches`,
|
|
225
|
+
// but stays freely writable by the user between two changes
|
|
226
226
|
readonly selectedBranch = linkedSignal(() => this.branches()[0]);
|
|
227
227
|
}
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
Without `linkedSignal()`, you would need an `effect()` calling `.set()` on a plain `signal()` on every change of `branches` — exactly the red flag seen above.
|
|
231
231
|
|
|
232
232
|
---
|
|
233
233
|
|
|
234
|
-
## 5. `untracked()
|
|
234
|
+
## 5. `untracked()`: controlling the dependencies of an `effect()`
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
An `effect()` subscribes to **every** signal read synchronously in its body — not only to the ones you had in mind as triggers. `untracked()` reads a value **without** creating this dependency.
|
|
237
237
|
|
|
238
238
|
```ts
|
|
239
239
|
effect(() => {
|
|
240
|
-
const id = this.userId(); //
|
|
241
|
-
const config = untracked(() => this.appConfig()); //
|
|
240
|
+
const id = this.userId(); // tracked
|
|
241
|
+
const config = untracked(() => this.appConfig()); // read, but not tracked
|
|
242
242
|
this.analytics.logUserView(id, config);
|
|
243
243
|
});
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
Most frequent use case in our components: an `effect()` that reads **and** possibly writes back the same signal (e.g. fixing a text `model()` according to a prefix).
|
|
247
247
|
|
|
248
|
-
⚠️ **
|
|
248
|
+
⚠️ **Pitfall to avoid**: here, you must **not** do `untracked(() => this.value())` on the main signal you want to fix — if you untrack it, the effect will never be triggered again when `value` changes elsewhere (e.g. the user typing), so no fix will happen anymore after the first run. `untracked()` is for excluding **secondary signals** that must not trigger the effect (a configuration flag for example) — not the main signal being observed.
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
The protection against the infinite loop doesn't come from `untracked()` in this case, but from **convergence**: the effect only writes if a condition really changes, so the second run (triggered by its own write) becomes a no-op.
|
|
251
251
|
|
|
252
252
|
```ts
|
|
253
|
-
private previousPrefix = DEFAULT_PREFIX; //
|
|
253
|
+
private previousPrefix = DEFAULT_PREFIX; // plain class field, not a signal: no need for reactivity on it
|
|
254
254
|
|
|
255
255
|
effect(() => {
|
|
256
|
-
let valueCopy = this.value(); //
|
|
257
|
-
const prefix = this.prefix(); //
|
|
256
|
+
let valueCopy = this.value(); // tracked: the effect MUST react to the changes of value
|
|
257
|
+
const prefix = this.prefix(); // tracked: the effect MUST react to the changes of prefix
|
|
258
258
|
|
|
259
|
-
// signal
|
|
259
|
+
// secondary signal, irrelevant for triggering this effect: untracked() is legitimate
|
|
260
260
|
if (untracked(() => this.trimOnBlur())) {
|
|
261
261
|
valueCopy = valueCopy?.trim() ?? null;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
//
|
|
264
|
+
// The prefix has changed: fix value and notify the parent
|
|
265
265
|
if (!valueCopy?.includes(prefix)) {
|
|
266
266
|
const withoutOldPrefix = valueCopy?.includes(this.previousPrefix)
|
|
267
267
|
? valueCopy.slice(this.previousPrefix.length)
|
|
268
268
|
: valueCopy;
|
|
269
269
|
|
|
270
|
-
this.value.set(withoutOldPrefix); //
|
|
271
|
-
this.onUserInput(withoutOldPrefix); //
|
|
270
|
+
this.value.set(withoutOldPrefix); // triggers this effect again (intended, see the comment below)
|
|
271
|
+
this.onUserInput(withoutOldPrefix); // real side effect (e.g. notify the parent)
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
// ⚠️
|
|
275
|
-
//
|
|
274
|
+
// ⚠️ The .set() above triggers this effect a 2nd time (expected):
|
|
275
|
+
// on the 2nd run, valueCopy.includes(prefix) becomes true → no more write → convergence.
|
|
276
276
|
this.previousPrefix = prefix;
|
|
277
277
|
});
|
|
278
278
|
```
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
What makes this `.set()` on `value` legitimate despite the red flag of section 2: the effect also calls `onUserInput()`, a real side effect (notifying the parent) — it's not a pure derivation, so not a disguised `computed()`.
|
|
281
281
|
|
|
282
|
-
⚠️ `untracked()`
|
|
282
|
+
⚠️ `untracked()` doesn't replace a convergence guard. It controls **what triggers** the effect (secondary signals to exclude), not the fact that a write on the main signal must be conditional to avoid an infinite loop — these are two different problems, which are often combined in the same effect as above.
|
|
283
283
|
|
|
284
284
|
---
|
|
285
285
|
|
|
286
|
-
## 6. RxJS interop
|
|
286
|
+
## 6. RxJS interop: signals **and** RxJS, not one against the other
|
|
287
287
|
|
|
288
|
-
> 🎤 **
|
|
288
|
+
> 🎤 **we don't migrate all the existing RxJS to signals**. Both models answer different needs, and mixing them in the wrong place is a pitfall too.
|
|
289
289
|
|
|
290
|
-
|
|
|
291
|
-
|
|
|
292
|
-
|
|
|
293
|
-
| Mode
|
|
294
|
-
|
|
|
290
|
+
| | Signal | Observable (RxJS) |
|
|
291
|
+
| ---------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
292
|
+
| Model | **State** — a value at a time T | **Stream** — a sequence of values over time |
|
|
293
|
+
| Mode | Pull (you read `signal()` when you want) | Push (you subscribe, you receive) |
|
|
294
|
+
| Strong for | Component state, synchronous derivations, template | Async (HTTP, WebSocket), operators (`debounceTime`, `switchMap`, `retry`, `combineLatest`...), request cancellation |
|
|
295
295
|
|
|
296
|
-
**
|
|
296
|
+
**Simple rule to give the team**: if the data represents "a current state", signal. If it represents "a sequence of events over time" with composition/cancellation to handle, RxJS remains the right tool — an HTTP call is the typical example.
|
|
297
297
|
|
|
298
|
-
### `toSignal()` —
|
|
298
|
+
### `toSignal()` — exposing an Observable as a Signal
|
|
299
299
|
|
|
300
|
-
|
|
300
|
+
The most common case: a service exposing an HTTP call as an `Observable`, consumed as a `Signal` in the component to avoid the `| async` in the template.
|
|
301
301
|
|
|
302
302
|
```ts
|
|
303
303
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
304
304
|
|
|
305
305
|
private readonly userService = inject(UserService);
|
|
306
306
|
|
|
307
|
-
// userService.getUser()
|
|
307
|
+
// userService.getUser() returns an Observable<User> (RxJS HTTP call, unchanged)
|
|
308
308
|
readonly user = toSignal(this.userService.getUser(), { initialValue: null });
|
|
309
309
|
```
|
|
310
310
|
|
|
311
|
-
-
|
|
312
|
-
-
|
|
313
|
-
-
|
|
311
|
+
- Behaves like the `| async` of the template, but usable everywhere (not only in the template)
|
|
312
|
+
- If the Observable completes, the signal keeps the last emitted value
|
|
313
|
+
- If the Observable errors, the error is thrown again when the signal is read
|
|
314
314
|
|
|
315
|
-
### `toObservable()` —
|
|
315
|
+
### `toObservable()` — exposing a Signal as an Observable
|
|
316
316
|
|
|
317
|
-
|
|
317
|
+
The other way around: useful when you want to pipe a signal (e.g. a search value typed by the user) into RxJS operators before triggering an HTTP call.
|
|
318
318
|
|
|
319
319
|
```ts
|
|
320
320
|
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
@@ -322,7 +322,7 @@ import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
|
|
|
322
322
|
|
|
323
323
|
readonly query = signal('');
|
|
324
324
|
|
|
325
|
-
//
|
|
325
|
+
// we keep RxJS for what RxJS does well: debounce + cancellation of in-flight requests
|
|
326
326
|
private readonly results$ = toObservable(this.query).pipe(
|
|
327
327
|
debounceTime(300),
|
|
328
328
|
distinctUntilChanged(),
|
|
@@ -332,13 +332,13 @@ private readonly results$ = toObservable(this.query).pipe(
|
|
|
332
332
|
readonly results = toSignal(this.results$, { initialValue: [] });
|
|
333
333
|
```
|
|
334
334
|
|
|
335
|
-
|
|
335
|
+
It's the most telling example to show the team: **the signal drives the user input, RxJS handles the debounce/switchMap/cancellation, and a signal comes out for the template.** Neither model is sacrificed.
|
|
336
336
|
|
|
337
|
-
⚠️ `toObservable()`
|
|
337
|
+
⚠️ `toObservable()` must be declared as a **class field** (and not called inside a method) because it needs an injection context when it is created.
|
|
338
338
|
|
|
339
|
-
### `rxResource()` —
|
|
339
|
+
### `rxResource()` — asynchronous calls driven by a signal, with loading/error state included
|
|
340
340
|
|
|
341
|
-
|
|
341
|
+
An alternative to `toSignal()` when you also want state tracking (loading, error, reload) without coding it by hand. `rxResource()` accepts an `Observable` as data source, and is triggered again automatically when the signals the `request` depends on change.
|
|
342
342
|
|
|
343
343
|
```ts
|
|
344
344
|
import { rxResource } from '@angular/core/rxjs-interop';
|
|
@@ -350,13 +350,13 @@ readonly userResource = rxResource({
|
|
|
350
350
|
loader: ({ request: id }) => this.userService.getUser(id), // Observable<User>
|
|
351
351
|
});
|
|
352
352
|
|
|
353
|
-
//
|
|
353
|
+
// in the template: userResource.value(), userResource.isLoading(), userResource.error()
|
|
354
354
|
```
|
|
355
355
|
|
|
356
|
-
**
|
|
356
|
+
**Concrete example — before/after on an HTTP call handled by hand:**
|
|
357
357
|
|
|
358
358
|
```ts
|
|
359
|
-
// 🔴
|
|
359
|
+
// 🔴 Before: manual subscribe() + loading/error variables handled by hand
|
|
360
360
|
readonly user = signal<User | null>(null);
|
|
361
361
|
readonly loading = signal(false);
|
|
362
362
|
readonly error = signal<unknown>(null);
|
|
@@ -371,7 +371,7 @@ ngOnInit(): void {
|
|
|
371
371
|
```
|
|
372
372
|
|
|
373
373
|
```ts
|
|
374
|
-
// 🟢
|
|
374
|
+
// 🟢 After: rxResource(), loading/error included, automatic re-fetch if userId() changes
|
|
375
375
|
readonly userResource = rxResource({
|
|
376
376
|
request: () => this.userId(),
|
|
377
377
|
loader: ({ request: id }) => this.userService.getUser(id),
|
|
@@ -379,30 +379,30 @@ readonly userResource = rxResource({
|
|
|
379
379
|
// userResource.value(), userResource.isLoading(), userResource.error()
|
|
380
380
|
```
|
|
381
381
|
|
|
382
|
-
###
|
|
382
|
+
### Should we drop the `async` pipe?
|
|
383
383
|
|
|
384
|
-
|
|
384
|
+
No, it isn't deprecated. But the recommended trend for new code is to prefer `toSignal()` in most cases:
|
|
385
385
|
|
|
386
|
-
- `toSignal()`
|
|
387
|
-
-
|
|
388
|
-
-
|
|
386
|
+
- `toSignal()` behaves like the `async` pipe, but the value obtained is usable **everywhere** — not only in the template (in a `computed()`, an `effect()`, as `input` of a child...)
|
|
387
|
+
- A **single subscription** even if the value is read several times in the template (the `async` pipe used twice on the same stream creates two distinct subscriptions, unless `shareReplay`)
|
|
388
|
+
- Reads consistent with the rest of the signal graph (important with `OnPush`/zoneless)
|
|
389
389
|
|
|
390
|
-
|
|
390
|
+
The `async` pipe stays fully legitimate when the `Observable` is consumed **only in the template**, in a single place, without the value needing to go to the rest of the class — converting it to a signal brings nothing in that case.
|
|
391
391
|
|
|
392
|
-
> 🎤
|
|
392
|
+
> 🎤 if the value is only used for display in one specific place, `| async` stays legitimate. If it is used elsewhere in the component (derived computation, passed to a child, logic in an effect), `toSignal()`.
|
|
393
393
|
|
|
394
|
-
###
|
|
394
|
+
### What we **don't** migrate
|
|
395
395
|
|
|
396
|
-
> 🎤
|
|
396
|
+
> 🎤 to avoid over-correcting after this session:
|
|
397
397
|
|
|
398
|
-
-
|
|
399
|
-
-
|
|
400
|
-
-
|
|
398
|
+
- HTTP calls stay `Observable`s at the service level (`HttpClient` returns RxJS, nothing changes here)
|
|
399
|
+
- Streams with composition/timing operators (`debounceTime`, `combineLatest`, `retry`, `race`...) stay in RxJS
|
|
400
|
+
- An `Observable` is converted to a `Signal` (with `toSignal()`) **only at the edge** — that is, at the very last moment, where the data leaves the RxJS world to become a state to display, never in the middle of a chain of operators
|
|
401
401
|
|
|
402
402
|
```ts
|
|
403
|
-
// 🔴
|
|
404
|
-
//
|
|
405
|
-
//
|
|
403
|
+
// 🔴 Premature conversion in the middle of the pipeline: a Signal
|
|
404
|
+
// isn't "pipeable", you have to go back through toObservable() to
|
|
405
|
+
// continue the composition — a useless round trip
|
|
406
406
|
private readonly debounced = toSignal(
|
|
407
407
|
toObservable(this.query).pipe(debounceTime(300)),
|
|
408
408
|
);
|
|
@@ -412,7 +412,7 @@ Le pipe `async` garde toute sa légitimité quand l'`Observable` n'est consommé
|
|
|
412
412
|
);
|
|
413
413
|
readonly results = toSignal(this.results$, { initialValue: [] });
|
|
414
414
|
|
|
415
|
-
// 🟢
|
|
415
|
+
// 🟢 A single conversion point, at the very end of the chain
|
|
416
416
|
private readonly results$ = toObservable(this.query).pipe(
|
|
417
417
|
debounceTime(300),
|
|
418
418
|
distinctUntilChanged(),
|
|
@@ -421,48 +421,48 @@ Le pipe `async` garde toute sa légitimité quand l'`Observable` n'est consommé
|
|
|
421
421
|
readonly results = toSignal(this.results$, { initialValue: [] });
|
|
422
422
|
```
|
|
423
423
|
|
|
424
|
-
|
|
424
|
+
It also applies between layers: a service can compose entirely in RxJS (`debounceTime`, `retry`, `combineLatest`...) and stay testable independently of any component — it's the consuming component, "at the edge", that converts to a signal when displaying.
|
|
425
425
|
|
|
426
426
|
---
|
|
427
427
|
|
|
428
|
-
## 7.
|
|
428
|
+
## 7. Pitfall #1: mutation vs reassignment
|
|
429
429
|
|
|
430
|
-
|
|
430
|
+
It's **the** reflex to unlearn when coming from the Zone.js era: before, mutating an array or a nested object "ended up working" because change detection regularly went over everything again. **With signals, this is no longer true**: only `.set()` / `.update()` notify the consumers. An in-place mutation is invisible to the signal, the template, and any `computed()`/`effect()` depending on it.
|
|
431
431
|
|
|
432
432
|
```ts
|
|
433
|
-
// 🔴
|
|
433
|
+
// 🔴 Silent no-op from the signal's point of view
|
|
434
434
|
this.address().locality.name = 'newName';
|
|
435
435
|
|
|
436
|
-
// 🟢
|
|
436
|
+
// 🟢 New top-level reference
|
|
437
437
|
this.address.update((current) => ({
|
|
438
438
|
...current,
|
|
439
439
|
locality: { ...current.locality, name: 'newName' },
|
|
440
440
|
}));
|
|
441
441
|
```
|
|
442
442
|
|
|
443
|
-
|
|
443
|
+
Same logic for the mutating methods of arrays:
|
|
444
444
|
|
|
445
445
|
```ts
|
|
446
|
-
// 🔴 push/splice/pop/shift/unshift/sort/reverse
|
|
446
|
+
// 🔴 push/splice/pop/shift/unshift/sort/reverse mutate in place
|
|
447
447
|
this.model().push({ name: null, address: null });
|
|
448
448
|
|
|
449
|
-
// 🟢
|
|
449
|
+
// 🟢 Always a new reference
|
|
450
450
|
this.model.update((current) => [...current, { name: null, address: null }]);
|
|
451
451
|
```
|
|
452
452
|
|
|
453
|
-
...
|
|
453
|
+
...and for `Map` / `Set` (a sneaky pitfall: `Map.prototype.set` has the same name as the `.set()` of the signal):
|
|
454
454
|
|
|
455
455
|
```ts
|
|
456
|
-
// 🔴
|
|
456
|
+
// 🔴 This is Map.prototype.set, not the signal's — it mutates the same instance
|
|
457
457
|
this.cache().set(key, value);
|
|
458
458
|
|
|
459
459
|
// 🟢
|
|
460
460
|
this.cache.update((current) => new Map(current).set(key, value));
|
|
461
461
|
```
|
|
462
462
|
|
|
463
|
-
> 💡
|
|
463
|
+
> 💡 if a method modifies "in place" and doesn't return a brand new object/array/Map/Set, it mutates — wrap it in a copy. `map`, `filter`, `slice`, `concat`, the spread: always safe.
|
|
464
464
|
|
|
465
|
-
**
|
|
465
|
+
**Concrete example — the most striking one to show the team:**
|
|
466
466
|
|
|
467
467
|
```ts
|
|
468
468
|
@Component({
|
|
@@ -471,21 +471,21 @@ this.cache.update((current) => new Map(current).set(key, value));
|
|
|
471
471
|
@for (contact of contacts(); track contact.id) {
|
|
472
472
|
<p>{{ contact.name }}</p>
|
|
473
473
|
}
|
|
474
|
-
<button (click)="addBroken()">
|
|
475
|
-
<button (click)="addFixed()">
|
|
474
|
+
<button (click)="addBroken()">Add (broken)</button>
|
|
475
|
+
<button (click)="addFixed()">Add (correct)</button>
|
|
476
476
|
`,
|
|
477
477
|
})
|
|
478
478
|
export class ContactListComponent {
|
|
479
479
|
readonly contacts = signal<Contact[]>([{ id: 1, name: 'Alice' }]);
|
|
480
480
|
|
|
481
481
|
addBroken(): void {
|
|
482
|
-
// 🔴 push()
|
|
483
|
-
//
|
|
482
|
+
// 🔴 push() mutates the array in place: the signal detects nothing,
|
|
483
|
+
// NOTHING is displayed on screen although the data is in memory
|
|
484
484
|
this.contacts().push({ id: 2, name: 'Bob' });
|
|
485
485
|
}
|
|
486
486
|
|
|
487
487
|
addFixed(): void {
|
|
488
|
-
// 🟢
|
|
488
|
+
// 🟢 new reference: the @for updates normally
|
|
489
489
|
this.contacts.update((current) => [...current, { id: 2, name: 'Bob' }]);
|
|
490
490
|
}
|
|
491
491
|
}
|
|
@@ -493,11 +493,11 @@ export class ContactListComponent {
|
|
|
493
493
|
|
|
494
494
|
---
|
|
495
495
|
|
|
496
|
-
##
|
|
496
|
+
## End-of-session checklist
|
|
497
497
|
|
|
498
|
-
-
|
|
499
|
-
-
|
|
500
|
-
-
|
|
501
|
-
-
|
|
502
|
-
-
|
|
503
|
-
-
|
|
498
|
+
- I know how to choose between `input()`, `model()`, `computed()`, `linkedSignal()` and `signal()`
|
|
499
|
+
- I know how to spot an `effect()` that should be a `computed()`
|
|
500
|
+
- I know how to spot an `effect()` that should be a `linkedSignal()`
|
|
501
|
+
- I never mutate a signal value in place anymore — always `.set()`/`.update()` with a new reference
|
|
502
|
+
- I know when to use `untracked()` in an `effect()`
|
|
503
|
+
- I don't try to replace all my RxJS with signals — I keep RxJS for composed async (HTTP, debounce, switchMap...) and I only convert to a signal at the edge, with `toSignal()`/`toObservable()`/`rxResource()`
|