シグナル
シグナルとは、値をラップして、その値が変わったときに自動的に反応できるしくみ。プリミティブ型からオブジェクトのような複雑な構造まで扱うことができる。シグナルを使うと、値の変化に応じて処理を自動で再実行できるので、UI の状態管理がシンプルになる。
Angular では従来、zone.js によって非同期処理後の変更検知を自動で行っていたが、これは不要な再描画や予測しづらい副作用の原因になりやすかった。その反省をふまえて、Angular は zone.js に依存しない zoneless なアーキテクチャへの移行を進めている。この新しい仕組みの中心にあるのがシグナルであり、今後の Angular アプリ開発では欠かせない存在になる。
書き込み可能なシグナル
書き込み可能なシグナルは、値を直接更新するためのメソッドを提供する。シグナルの初期値を指定して signal
関数を呼び出すことで作成する。書き込み可能なシグナルは、WritableSignal
という型になる。
const count = signal(0);
console.log('The count is: ' + count());
値を変更するには、set()
で直接設定する。
count.set(3);
前の値を利用する場合は、update()
を使用する。
count.update(value => value + 1);
算出シグナル
算出シグナルは、ほかのシグナルから値を算出する読み取り専用のシグナル。computed
関数を使用して、算出元を指定することで定義する。
const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);
doubleCount
シグナルは、count
シグナルに依存している。count
が更新されるたびに、AngularはdoubleCount
も更新する必要があることを認識する。
算出シグナルは、値を読み取るまでその値の計算が実行されない。そして、計算された値はキャッシュされ、再び読み取るときはキャッシュした値を返す。もちろん、算出元のシグナル値が変わった場合は新しい値を算出する必要があると認識する。
エフェクト
エフェクトは、1つ以上のシグナル値が変更されたときに実行される処理を定義することができる。effect
関数を使って定義し、その中で参照されたシグナルに依存するようになる。
effect(() => {
console.log(`The current count is: ${count()}`);
});
このエフェクトは、count
の値が変わるたびに再実行される。最初の実行時にも1回動作し、依存するシグナルが変更されるたびに再評価される。
エフェクトはインジェクションコンテキスト内(inject
関数にアクセスできる場所)でのみ作成できる。constructor
内で作成するのが一般的。
等価関数
シグナルを作成するときに、オプションで等価関数を指定できる。これは、新しい値と前の値が異なるかどうかを確認するために使われる。
import _ from 'lodash';
const data = signal(['test'], {equal: _.isEqual});
// これは別の配列インスタンスだが、
// 深い等価関数を使用することで値は等しいと判断され、
// シグナルは更新をトリガーしない
data.set(['test']);
依存関係を作らない
computed
や effect
内で複数のシグナルを読む場合、あるシグナルには依存したくないときは untracked
を使用する。
effect(() => {
console.log(`User set to ${currentUser()} and the counter is ${untracked(counter)}`);
});
これにより counter
が変更されていても、このエフェクトは実行されない。
エフェクトのクリーンアップ
エフェクトは非同期処理やタイマーなどの「長時間動作する処理」を始めることがある。このような処理は、エフェクトが再実行されたり破棄されたりしたときに、前の処理をキャンセル・後片付けする必要がある。そのために、effect
関数は 最初の引数として onCleanup
関数を受け取れる。
effect((onCleanup) => {
const user = currentUser();
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
onCleanup(() => {
clearTimeout(timer);
});
});
リンクされたシグナル
linkedSignal
は、既存のシグナルや状態に「リンクされた」書き込み可能なシグナルを作成する仕組み。初期値の代わりに computed
のような算出関数を渡すことで、他の状態に応じた動的な値を保てる。
@Component({/* ... */})
export class ShippingMethodPicker {
shippingOptions: Signal<ShippingMethod[]> = getShippingOptions();
selectedOption = linkedSignal(() => this.shippingOptions()[0]);
changeShipping(index: number) {
this.selectedOption.set(this.shippingOptions()[index]);
}
}
上記の例では、shippingOptions
の内容が変わると selectedOption
も自動的に更新され、常にリストの先頭のオプションが選択される。しかし実際には、ユーザーが以前に選択したオプションがまだ存在していれば、その選択を維持したい場面がある。このような要件に対応するため、linkedSignal
は source
と computation
を個別に指定できる。computation
関数は、現在の source
の値に加えて、前回の値(previous.source
と previous.value
)も参照できるため、より柔軟な更新ロジックが記述できる。
interface ShippingMethod {
id: number;
name: string;
}
@Component({/* ... */})
export class ShippingMethodPicker {
constructor() {
this.changeShipping(2);
this.changeShippingOptions();
console.log(this.selectedOption()); // {"id":2,"name":"Postal Service"}
}
shippingOptions = signal<ShippingMethod[]>([
{ id: 0, name: 'Ground' },
{ id: 1, name: 'Air' },
{ id: 2, name: 'Sea' },
]);
selectedOption = linkedSignal<ShippingMethod[], ShippingMethod>({
source: this.shippingOptions,
computation: (newOptions, previous) => {
return (
newOptions.find((opt) => opt.id === previous?.value.id) ?? newOptions[0]
);
},
});
changeShipping(index: number) {
this.selectedOption.set(this.shippingOptions()[index]);
}
changeShippingOptions() {
this.shippingOptions.set([
{ id: 0, name: 'Email' },
{ id: 1, name: 'Sea' },
{ id: 2, name: 'Postal Service' },
]);
}
}
この例では、shippingOptions
が更新された際、以前に選ばれていたオプションが新しいリストにも存在する場合はそれを保持し、存在しない場合はリストの先頭を選択する。linkedSignal
を使うことで、単純な依存関係では難しい「状態を保持したまま更新に対応する」ようなケースに柔軟に対応できる。
computed
は読み取り専用の自動計算シグナル。
linkedSignal
は書き込みもでき、前回の値を参照した柔軟な計算が可能。