Angular 17 dependent signal change not detected?

1.5k views Asked by At

Hello I have just started playing around with Angular Signals. Now I have a list of persons which I added paging to which I display in my template. Whenever I change the page it updates the pageState object which is also a signal. Now I thought that by consuming both persons and pagingState in the filteredPersons it would pickup the changes but that does not seem to be the case.

Standalone Component


@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet],
  template: `
  <ul>
    @for(person of filteredPersons(); track person.name) {
      <li>
        {{ person.name }} has {{ person.money }} dollars
      </li>
    }
  </ul>
  <button (click)="previousPage()">Previous Page</button>
  <button (click)="nextPage()">Next Page</button>
  <small>Page {{ pagingState().page }} of {{ totalPages() }}</small>
  `,
  styles: [],
})
export class AppComponent {

  private persons = this.personService.persons;

  filteredPersons = computed(() => {
    console.log('One of the dependents has changed :D')
    const persons = this.persons();
    const pagingState = this.pagingState();
    const amountToSkip = (pagingState.page - 1) * pagingState.pageSize;
    return persons?.slice(amountToSkip, amountToSkip + pagingState.pageSize);;
  })

  pagingState = signal({
    page: 1,
    pageSize: 5
  })

  totalPages = computed(() => Math.ceil(this.persons()?.length / this.pagingState().pageSize));

  constructor(private personService: PersonService) {}

  nextPage(): void {
    this.pagingState.update((state) => {
      if (state.page < this.totalPages()) state.page += 1
      return { ...state}; // Cant return state directly since it wont pickup the change :(
    })
  }

  previousPage(): void {
    this.pagingState.update((state) => {
      if (state.page > 1) state.page -= 1
      return { ...state}; // Cant return state directly since it wont pickup the change :(
    })
  }
}

PersonService


  private _persons = signal<Person[]>(undefined);
  get persons() {
    return this._persons.asReadonly();
  }

  hasLoaded = computed(() => this.persons() !== undefined);

  constructor() {
    this.refresh();
  }

  getAll(): Observable<Person[]> {
    return of([
{
        name: 'Alice',
        money: 150,
      },
      {
        name: 'Bob',
        money: 280,
      },
      {
        name: 'Carol',
        money: 210,
      },
      {
        name: 'David',
        money: 320,
      },
      {
        name: 'Eva',
        money: 180,
      },
      {
        name: 'Frank',
        money: 270,
      },
      {
        name: 'Grace',
        money: 190,
      },
      {
        name: 'Helen',
        money: 230,
      },
      {
        name: 'Ivan',
        money: 260,
      },
      {
        name: 'Jack',
        money: 290,
      },
      {
        name: 'Karen',
        money: 220,
      },
      {
        name: 'Leo',
        money: 240,
      },
      {
        name: 'Mia',
        money: 200,
      },
      {
        name: 'Nina',
        money: 310,
      },
      {
        name: 'Oliver',
        money: 170,
      },
      {
        name: 'Paul',
        money: 300,
      }
    ]).pipe(delay(500))
  }

  refresh(): void {
    this._persons.set(undefined);
    this.getAll().subscribe((persons) => {
      this._persons.set(persons);
    });
  }

I am pretty confident it doesn't pickup the change because it doesnt see the change. I am now returning a shallow copy of the updated pagingState but that solutions feel wrong to me. Can anybody explain why this is happening? If this is expected behavior it feels really counterintuitive.

1

There are 1 answers

0
tsimon On

As you are probably discovering, the API for signals changed between Angular 16 (when it was in developer preview) and Angular 17, where 'mutate' was replaced with 'update'.

Update checks reference equality to determine if an object has changed, so you have to create an entirely new copy of the object for Angular to recognise the change. That might look like this:

export class SignalUtil {

    // See: https://stackoverflow.com/questions/76726079/why-doesnt-angular-signals-run-my-custom-equal-check-when-using-mutate
    public static mutate<T>(
        signal: WritableSignal<T>,
        mutatorFn: (value: T) => void
    ) {
        const val = signal();
        const clone = JSON.parse(JSON.stringify(val));
        const mutated = mutatorFn(clone);
        
        signal.set(mutated)
    }
}

And this works OK, but this implementation has a few problems: this will create a lot of garbage memory, and will break if you use objects (i.e. with functions) instead of structs inside your signals.

A better option is to install immer in your project, which is a tool for functional programming that knows how to clone and handle objects how you would like. A better implementation (using the produce() function from immer) looks like this:

import {
    WritableSignal,
} from '@angular/core';
import {
    produce
} from 'immer';

export class SignalUtil {

    // See: https://stackoverflow.com/questions/76726079/why-doesnt-angular-signals-run-my-custom-equal-check-when-using-mutate
    public static mutate<T>(
        signal: WritableSignal<T>,
        mutatorFn: (value: T) => void
    ): boolean {
        const newState = produce(signal(), (draftState: any) => {
            mutatorFn(draftState);
        });

        // this assumes that immer returns the
        // original object instance if there were no changes
        const hasChanged = signal() !== newState;

        if (hasChanged) {
            // update the signal value if we have new state
            signal.set(newState);
        }

        return hasChanged;
    }
}