😵‍💫

【Angular】Computed Signal が更新されない!

に公開2

はじめに

v16 から Angular に Signal が導入されました。
シンプルな記述でリアクティブな値を管理でき、非常に便利です。
また、Signal を使った変更検知は、従来の zone.js によるものと異なり、コンポーネントツリー全体を確認する必要がないため、パフォーマンスの向上も期待できます。
しかし、使い勝手が良い反面、注意が必要な現象に遭遇し沼ったため、備忘録として残します。

Computed Signal とは

Computed Signal とは、Signal から派生した値を持つ読み取り専用の Signal です。
以下のように、computed関数を使って定義します。

const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);

Computed Signal が更新されないケース

Computed Signal を定義時点で、computed関数に渡す Signal を評価できないと、更新を追跡できないようです。
配列やオブジェクトのイテレーションメソッドに渡すコールバック関数で Signal を評価する場合、そういったことが起こえます。

以下のように、初期値がundefinedの配列undefinedNumbersのイテレーションメソッド内では、定義時点で this.count() を評価できません。
結果、後からundefinedNumbersに値を代入しても、countの更新を追跡できず、undefinedComputedSignalundefinedのままになってしまうようです。

main.ts
import { Component, computed, signal } from "@angular/core";
import { bootstrapApplication } from "@angular/platform-browser";

@Component({
  selector: "app-root",
  template: `
    <div>undefined computed signal: {{ undefinedComputedSignal() }}</div>
    <button (click)="countUp()">count up</button>
  `,
})
export class App {
  count = signal(0);
  undefinedNumbers: number[];
  undefinedComputedSignal = computed(() =>
    this.undefinedNumbers?.find((number) => number === this.count())
  );

  countUp() {
    this.count.update((count) => count + 1);
    this.undefinedNumbers = [5, 10, 15];
  }
}

bootstrapApplication(App);

回避策

以下のように、イテレーションメソッドのコールバック関数外で参照を作ってあげると、countの更新を追跡してくれます。

main.ts
import { Component, computed, signal } from "@angular/core";
import { bootstrapApplication } from "@angular/platform-browser";

@Component({
  selector: "app-root",
  template: `
-   <div>undefined computed signal: {{ undefinedComputedSignal() }}</div>
+   <div>undefined computed signal2: {{ undefinedComputedSignal2() }}</div>
    <button (click)="countUp()">count up</button>
  `,
})
export class App {
  count = signal(0);
  undefinedNumbers: number[];
- undefinedComputedSignal = computed(() =>
-   this.undefinedNumbers?.find((number) => number === this.count())
- );
+ undefinedComputedSignal2 = computed(() => {
+   const count = this.count();
+   return this.undefinedNumbers?.find((number) => number === count);
+ });

  countUp() {
    this.count.update((count) => count + 1);
    this.undefinedNumbers = [5, 10, 15];
  }
}

bootstrapApplication(App);

StackBlitz

検証のために遊んだ StackBlitz を貼っておきます。

https://stackblitz.com/edit/stackblitz-starters-57skebhe?file=src%2Fmain.ts

おわりに

Computed Signal は定義時に評価した Signal の更新を追跡するようです。
紹介したケースのように、undefined, [], {}のイテレーションメソッド内に追跡したい Signal を書くと、この不具合の発生リスクがあるため、注意が必要です。

最後に、本記事の内容は、あくまで実験の結果であって、Signalの挙動を保証するものではない点にはご注意ください。

参考

Discussion

ロコペリロコペリ

非常に分かりやすい記事、助かります!
私が考えた回避策を共有いたします。

回避策1

記事にもある通り、AngularはComponentの初期化処理を行う際に、computedの変更検知の追跡を登録します。
その為、その時に変更検知の追跡を登録できなければ、signalの変更を検知して、computedが実行されることはありません。
しかし、undefinedNumbersをSignalにするとcount自体の変更検知の追跡は登録されませんが、
undefinedNumbersの変更検知の追跡は登録されます。
undefinedNumbersにsetしたタイミングで、computedが実行され、find関数が実行されます。
find関数が実行されるとcountの変更検知の追跡が登録され、countだけを変更しても実行されるようになると思います。
これは、computedの変更検知の追跡は動的に登録される(算出シグナルの依存関係は動的である)ことからこのような動きになります。

export class App {
  count = signal(0);
  undefinedNumbers: signal<number[]>([]);
  undefinedComputedSignal = computed(() =>
    this.undefinedNumbers()?.find((number) => number === this.count())
  );

  countUp() {
    this.count.update((count) => count + 1);
    this.undefinedNumbers.set([5, 10, 15]);
  }
}

回避策2

配列の値を入れるタイミングを、メソッドが実行されたタイミングではなく、ngOnInitのタイミングで行う場合です。
AngularがSignalの変更検知の追跡を登録するのは、ngOnInitが実行された後になるので、ngOnInitで値を入れても問題なく実行されます。

export class App implements OnInit {
  count = signal(0);
  undefinedNumbers: number[];
  undefinedComputedSignal = computed(() =>
    this.undefinedNumbers?.find((number) => number === this.count())
  );

  ngOnInit() {
    this.undefinedNumbers = [5, 10, 15];
  }

  countUp() {
    this.count.update((count) => count + 1);
  }
}
churuchuruchuruchuru

ロコペリさん、ありがとうございます!
勉強になります!

これは、computedの変更検知の追跡は動的に登録される(算出シグナルの依存関係は動的である)ことからこのような動きになります。

この観点から computed に追跡したい子を登録できているか意識しないとですね!