Update Map in a Signal, I wished someone told me before I made this mistake.
Connie Leung
Posted on October 22, 2024
A subtle bug can occur when updating a Map stored in an Angular signal, primarily due to how change detection works with object references. When keys are added to the Map, the reference to the original Map remains unchanged, as the operation modifies the existing object in place rather than creating a new instance. Consequently, Angular's change detection mechanism fails to recognize that the signal has been updated, leading to a situation where the view and computed signals in components do not reflect the latest state of the Map.
Allow me to explain the issue and provide a step-by-step solution.
Update a Map in a Signal - Did not work version
export type Data = {
name: string;
count: number;
}
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) {
const newCount = (dataMap.get(name) || 0) + count;
if (newCount <= 0) {
dataMap.delete(name);
} else {
dataMap.set(name, newCount);
}
return dataMap;
}
This function removes the key from the map when the count is non-positive. Otherwise, the existing key is updated with the new count. The function modifies the map's content and returns it to update the signal.
// main.ts
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalObjectComponent, AppSignalMapDataComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<div>
<p>aMap and champ works in the current component because the equal function always returns false</p>
@for (entry of aMap(); track entry[0]) {
<p>{{ entry[0] }} - {{ entry[1] }}</p>
}
<p>Most Popular: {{ champ()?.[0] || '' }}</p>
</div>
`,
})
export class App {
aMap = signal<Map<string, number>>(new Map(MY_MAP));
champ = computed(() => {
let curr: [string, number] | undefined = undefined;
for (const entry of this.aMap()) {
if (!curr || curr[1] < entry[1]) {
curr = entry;
}
}
return curr;
});
addBanana() {
const data = { name: 'banana', count: 10 };
this.updateMaps(data);
}
updateMaps(data: Data) {
this.aMap.update((prev) => updateAndReturnMap(prev, data));
}
}
MY_MAP
is a Map that contains a key, orange, with the value 3. I make a copy of MY_MAP
and create a signal named aMap
with the default options. The champ
computed signal iterates the map to find the key with the highest value. The HTML template has a @for
loop to display the map entries, a paragraph element to display the value of the champ
signal, and a button to add [banana, 10]. After I click the button, the new map entry is displayed, but the champ's
value is incorrect. The default equal function uses triple equals (===) to compare values; therefore, the function compares the map's reference. However, the map's reference has not been modified; only a new key has been added. Therefore, the application does not recompute the champ
computed signal.
This is a bug, but it is easy to resolve by overriding the equal function.
Override the signal’s equal function
aMap = signal<Map<string, number>>(new Map(MY_MAP), { equal: () => false });
I override the equal function to always return false in the signal function's options. Therefore, the signal is considered changed; the component is dirty and needs to be updated during change detection. The champ computed signal depends on the aMap signal, which is recomputed. The template displays the value of both aMap and champ; therefore, the view is also re-rendered. There are better solutions than this because it can lead to unnecessary change detection cycles, but it fixes the bug.
Let's say I refactor the App component to move the for loop and the logic of champ signals into a new component for reuse.
// map-data.component.html
<div>
<p>{{ title }}</p>
@for (entry of mapData(); track entry[0]) {
<p>{{ entry[0] }} - {{ entry[1] }}</p>
}
<p>Most Popular: {{ mostPopular() }}</p>
</div>
// signal-map-data.component.ts
@Component({
selector: 'app-signal-map-data',
standalone: true,
templateUrl: `./map-data.component.html`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppSignalMapDataComponent {
mapData = input.required<Map<string, number>>();
title = inject(new HostAttributeToken('title'), { optional: true }) || 'Signal';
champ = computed(() => {
let curr: [string, number] | undefined = undefined;
for (const entry of this.mapData()) {
if (!curr || curr[1] < entry[1]) {
curr = entry;
}
}
return curr;
});
mostPopular = computed(() => this.champ()?.[0] || '');
}
Next, I import the AppSignalMapDataComponent
component into the App
component and use it to display the same data in the HTML template.
// main.ts
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { ... same logic as before … }
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalMapDataComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<app-signal-map-data [mapData]="aMap()" title='It does not work because the map reference does not change.' />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
aMap = signal<Map<string, number>>(new Map(MY_MAP), { equal: () => false });
addBanana() { … same logic … }
updateMaps(data: Data) {
this.aMap.update((prev) => updateAndReturnMap(prev, data));
}
}
I click the button to add the new key, banana, to the map. However, the AppSignalMapDataComponent
component does not display the correct result. Why?
This is because the reference to the mapData
input stays unchanged. Therefore, change detection does not update the view and the computed signals in the AppSignalMapDataComponent
component.
Instead of passing aMap
directly to the signal input, I make a copy of aMap
and pass the new instance to it.
Make a new instance of the Map
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { … same logic as before … }
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalMapDataComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<app-signal-map-data [mapData]="aDeepCopyMap()" title='Signal with a new map' />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
aDeepCopyMap = signal<Map<string, number>>(new Map(MY_MAP));
addBanana() { … same logic… }
updateMaps(data: Data) {
this.aDeepCopyMap.update((prev) => updateAndReturnMap(new Map(prev), data));
}
}
The aDeepCopyMap
is a signal that stores a Map. The updateMaps
method calls new Map(prev)
to create a new Map and pass it to the updateAndReturnMap
function to add the key, banana, and value. The aDeepCopyMap's update
method updates the signal with the new Map. The signal input receives a new reference and triggers change detection to update the component's view and computed signals. The AppSignalMapDataComponent
component displays the correct results in the HTML template.
This is a reasonable solution because calling the Map constructor is not expensive. The last solution is to store the map in an Object and create a new Object reference after each map operation.
Change the data structure to store an Object in the signal
// signal-object.component.ts
@Component({
selector: 'app-signal-object',
standalone: true,
templateUrl: './map-data.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppSignalObjectComponent {
store = input.required<{ map: Map<string, number> }>();
mapData = computed(() => this.store().map);
champ = computed(() => {
let curr: [string, number] | undefined = undefined;
for (const entry of this.store().map) {
if (!curr || curr[1] < entry[1]) {
curr = entry;
}
}
return curr;
});
mostPopular = computed(() => this.champ()?.[0] || '');
title = 'Signal is an Object with a Map';
}
// main.ts
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { ... same logic as before ... }
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalObjectComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<app-signal-object [store]="this.store()" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
store = signal({ map: new Map(MY_MAP) });
addBanana() { ...same logic... }
updateMaps(data: Data) {
this.store.set({ map: updateAndReturnMap(this.store().map, data) });
}
}
The store
is a signal that stores an Object containing a Map. The updateMaps
method calls the updateAndReturnMap
function to add the key, banana, and value to the same Map object. The method creates a new Object, { map: <a Map Object> }
, and calls the store's set
method to overwrite the signal. The signal receives a new reference, and change detection occurs. The view, signal input, and computed signals of the AppSignalObjectComponent component update because of it.
Conclusions:
- Adding a key to a Map does not trigger change detection because the map's reference is not mutated.
- The easy solution is to override the equal function of the Signal to always return false. The same issue arises if Map is the component's signal input. - This is because the reference to the signal input is not mutated when only adding keys to the Map.
- I call the Map constructor to create a new Map and update the signal. The reference to the signal input changes and change detection occurs to update the view and signals.
- The last solution is to store the Map in an Object, and the signal stores the Object reference instead. After adding a key to the Map, I create a new Object and overwrite the signal. Signal input obtains a new reference, and change detection occurs. Similarly, the component updates the view, input, and the computed signals.
References:
Posted on October 22, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 30, 2024
October 22, 2024
September 30, 2024
September 24, 2024