Update Map in a Signal, I wished someone told me before I made this mistake.

railsstudent

Connie Leung

Posted on October 22, 2024

Update Map in a Signal, I wished someone told me before I made this mistake.

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;
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

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));
 }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
// 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] || '');
}
Enter fullscreen mode Exit fullscreen mode

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));
 }
}
Enter fullscreen mode Exit fullscreen mode

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));
 }
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode
// 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) });
 }
}
Enter fullscreen mode Exit fullscreen mode

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:

💖 💪 🙅 🚩
railsstudent
Connie Leung

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