javascript / expert
Snippet
Immutable Array Reconciliation for TrackBy-Optimized Lists
When a live feed re-emits a full array on every tick, naively swapping the whole array forces Angular's `@for` block to re-evaluate every row even if only one changed, and worse, breaks referential equality for child `OnPush` components even when their data is identical. `RowReconciler.merge` performs a keyed diff: unchanged rows keep their original object reference (`existing`, not `next`), so `OnPush` child components skip re-rendering entirely; only rows whose `version` actually advanced get replaced. Combined with `track row.id`, Angular's reconciliation avoids destroying and recreating DOM nodes for rows that merely shifted position.
snippet.js
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
interface Row { id: string; version: number; label: string; }@Injectable({ providedIn: 'root' })export class RowReconciler {merge(current: readonly Row[], incoming: readonly Row[]): Row[] {const incomingById = new Map(incoming.map((row) => [row.id, row]));const merged: Row[] = [];for (const existing of current) {const next = incomingById.get(existing.id);if (!next) continue;merged.push(next.version === existing.version ? existing : next);incomingById.delete(existing.id);}for (const remaining of incoming) {if (incomingById.has(remaining.id)) {merged.push(remaining);}}return merged;}}@Component({selector: 'app-row-list',standalone: true,template: `@for (row of rows(); track row.id) {<app-row [data]="row" />}`,})export class RowListComponent {private readonly reconciler = inject(RowReconciler);private readonly source = inject(RowFeed).stream();readonly rows = toSignal(this.source.pipe(scan((acc, incoming) => this.reconciler.merge(acc, incoming), [] as Row[])),{ initialValue: [] },);}
angular
Breakdown
1
merged.push(next.version === existing.version ? existing : next);
Preserves object identity for unchanged rows so downstream `OnPush` components see the same reference and skip change detection.
2
const incomingById = new Map(incoming.map((row) => [row.id, row]));
Builds a lookup index once up front, turning what would be an O(n²) nested-array search into O(n) map lookups.
3
this.source.pipe(scan((acc, incoming) => this.reconciler.merge(acc, incoming), [] as Row[]))
RxJS `scan` threads the previous merged array as the accumulator into each new reconciliation pass, making the diff stateful across emissions.