Angular v22ではSignals API ファミリーに debounced が新たに追加される見込みだ。このAPIについてユースケースとメカニズムを解説する。
debounced()
debounced関数は、ソースとなるSignalの変更が高頻度であるときに、一定の待機時間を備えたResourceオブジェクトを返す。ソースの変更があったあと、待機時間の間に追加の変更がなければその値で確定する。メンタルモデルはjQueryやRxJSのdebounceと似たようなものだ。
export function debounced<T>(
source: () => T,
wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>,
options?: NoInfer<DebouncedOptions<T>>,
): Resource<T>
次の擬似コードは振る舞いを示す。Signalは常に同期的に値を返すデータモデルだが、debounced関数が返すのはResourceオブジェクトだ。そのvalueプロパティは待機時間の間は変わらないSignalを返す。待機時間の間はResourceのisLoadingプロパティもTrueとなる。
const source = signal('initial');
const res = debounced(source, 200);
source(); // => initial
res.value(); // => initial
res.isLoading(); // => false
source.set('updated');
source(); // => updated
res.value(); // => initial
res.isLoading(); // => true
tick(200);
source(); // => updated
res.value(); // => updated
res.isLoading(); // => false
具体的なユースケースはSignal Formsとの連携が主だろう。テキストフィールドにバインドされたSignalをもとにHTTPリクエストが発生するようなケースでは、ユーザーの入力を間引くことになる。たとえば、次のように使えばユーザー名フィールドの入力を200ms間隔で待機した値でAPIを呼び出すHTTP Resourceを作成できる。
const usernameForm = form(signal('foobar'));
const res = httpResource(() => `/api/users/${debounced(usernameForm.value, 200)}`);
似たユースケースとしてSignal Formsにおける非同期バリデーションがある。これは組み込みの機能として、validateHttp関数にdebounceオプションが追加されており、フォーム値の更新を間引いてHTTP経由のバリデーションを実行するものだ。内部ではdebounced関数が呼び出されている。
const usernameForm = form(
signal('foobar'),
(p) => {
validateHttp(p, {
request: ({value}) => `/api/check?username=${value()}`,
debounce: 50, // Short debounce
onSuccess: (available: boolean) => (available ? undefined : {kind: 'username-taken'}),
onError: () => null,
});
},
{injector},
);
これでdebounced関数がどういうものなのかはだいたい説明できただろう。ここからはそのメカニズムを確認する。
メカニズム
先日書いたFirestoreをラップした例のように、Resourceはインターフェースであり、その構築方法は自由だ。組み込みのresource関数やhttpResource関数でなくても、Resourceインターフェースに従ったオブジェクトを作ることはできる。実際のdebounced関数はフレームワーク内部の細かいエラーハンドリングなど含めて複雑な実装だが、簡易的に自作のシンプルなdebounced関数を作りながらメカニズムを理解してみよう。
まず基本形として、何もしないResourceを返す関数を作ってみよう。Angular v21.2以降であれば、resourceFromSnapshots関数を使うことで、特定の型を持つSignalを元にResourceへ変換できる。
function debounced<T>(source: () => T): Resource<T> {
const state = signal<ResourceSnapshot>({
status: 'resolved',
value: untracked(() => source()),
});
return resourceFromSnapshots(state);
}
これだけではsourceの変化がResourceに伝播しない。effectを使って、sourceが変化したときにstateを更新する必要がある。
function debounced<T>(source: () => T): Resource<T> {
const state = signal<ResourceSnapshot>({
status: 'resolved',
value: untracked(() => source()),
});
effect(() => {
const changedValue = source();
state.set({
status: 'resolved',
value: changedValue,
});
});
return resourceFromSnapshots(state);
}
次に、待機時間を設ける。引数で間隔を受け取り、それをsetTimeoutに渡すことでstateへの値の反映を遅延させる。遅延させている間はstateをloading状態にしておく。
function debounced<T>(source: () => T, wait: number): Resource<T> {
const state = signal<ResourceSnapshot>({
status: 'resolved',
value: untracked(() => source()),
});
effect(() => {
const changedValue = source();
setTimeout(()=> {
state.set({
status: 'resolved',
value: changedValue,
});
}, wait);
state.set({
status: 'loading',
value: state.value(),
});
});
return resourceFromSnapshots(state);
}
これではただ遅延させているだけだ。遅延時間中に追加の変更が発火したら、進行中の待機時間は破棄して、新たに値の安定を待つ必要がある。この非同期的な状態を保持するために、activePromiseとpendingValueというローカル変数を導入しよう。setTimeoutによる遅延したコールバックの中で、activeが一致すれば追加の変更がなかったことになる。
function debounced<T>(source: () => T, wait: number): Resource<T> {
const state = signal<ResourceSnapshot>({
status: 'resolved',
value: untracked(() => source()),
});
effect(() => {
const changedValue = source();
const waiting = new Promise(resolve => {
setTimeout(resolve, wait)
});
const activePromise = waiting;
const pendingValue = changedValue;
waiting.then(() => {
// 割り込みの変更があればactivePromiseが不一致になる
if (waiting === activePromise) {
state.set({
status: 'resolved',
value: pendingValue,
});
}
});
state.set({
status: 'loading',
value: state.value(),
});
});
return resourceFromSnapshots(state);
}
これで簡易的なdebounced関数の出来上がりだ。実際のフレームワークでの実装とは細かい部分で違うが、基本的な設計はこのようになっている。中身はただPromiseとタイマーで状態管理しているだけのシンプルなものだ。
何が言いたいかと言うと、Resource型を返す関数を作るのは簡単だということだ。非同期性を持つ処理をSignalに統合したいとき、組み込みのAPIが上手くフィットしなかったとしても自作するハードルは低い。そのひとつの例が前回のFirestore CollectionのResource化だった。
まとめ
- Angular v22で導入予定の
debounced()は高頻度に変化する Signal を入力として、一定時間値が落ち着いたタイミングで確定値を出すResourceを返す。 - 典型的な用途は、フォーム入力に紐づく HTTP Resource や、Signal Forms の非同期バリデーションなどの「入力の間引き」。
- 実装の要点は、
effectで入力変化を監視し、タイマーと Promise で最新の待機を管理して確定時にResourceSnapshotをresolvedに更新する。 Resource型を返す関数を作るのは難しくない。非同期的なデータソースをSignalに統合するために使える便利なインターフェースだ。