Angular Signalsで柔軟なeffect管理を実現する拡張パターン
Angular Signalの effect
は便利ですが、ちょっとした落とし穴もあります。特に「同じ値を再セットしたときに反応しない」点は、実装中に引っかかる人も多いのではないでしょうか?
例えば、以下のようなコードを考えてみましょう:
@Component({...})
class ExampleComponent {
page = signal<number>(0);
constructor() {
effect(() => {
// pageの値が変更されたときに実行される
console.log('page changed:', this.page());
});
}
}
このコードでは、page
の値が変更されるたびに effect
が実行されます。Memo化されているので、値がセットされても変更がない場合は実行されません。
しかし、実際のアプリケーションでは、値が変更されていなくても、他の処理を実行したいことがあります。例えば、 page.set(0)
をトリガーにして、データの再読み込みを行いたい場合などです。Ionicのコンポーネントでいう ion-refresher
の実行などもそれに該当します。
Signalの拡張関数の実装
こうした問題に対応するために、 effect
がトリガーされる仕組みを明示的に作る拡張関数を考えてみました。以下のように実装できます:
export const pageSignal = (): WritableSignal<number> & {
trigger: WritableSignal<number>;
refresh: () => void;
} => {
const page = signal<number>(0);
const trigger = signal<number>(0);
return Object.assign(page, {
trigger,
refresh: () => {
page.set(0);
trigger.update((val) => val + 1);
},
});
}
もしも page
の値をセットしても変更がない時でも effect
や computed
が追跡できるように trigger
Signalを用意しています。また、 page
と trigger
を同時に更新できるように refresh
メソッドも用意しました。
拡張関数のユースケースと利点
この拡張関数を使う最大の利点は、「ページを進める」「更新する」といったユーザの操作ロジックと、データ取得の実装を別々に管理できるようになることです。従来の実装では、ページ番号の更新や再読み込みといった操作と、それに応じたデータの取得処理が密接に絡み合っていて、コンポーネントが複雑になりがちでした。
例えば「ページを0に戻してデータを再読み込みする」といった処理も、状態の変更とデータ取得の両方をその場で意識して書く必要があり、コードの見通しが悪くなります。
@Component({...})
class ExampleComponent {
private http = inject(HttpClient);
page = signal<number>(0);
items = signal<Item[]>([]);
ngOnInit() {
// 初回読み込み
this.loadData(this.page());
}
refresh(event: RefresherCustomEvent) {
// ページを0に戻してデータを再読み込み
this.page.set(0);
this.loadData(this.page()).finally(() => event.target.complete());
}
loadMore(event: InfiniteScrollCustomEvent) {
this.page.update(page => page + 1);
this.loadData(this.page()).finally(() => event.target.complete());
}
private async loadData(page: number) {
const data = await firstValueFrom(this.http.get<Item[]>('/api/users/' + page));
this.items.update(items => {
if (page === 0) {
return [...data]; // ページが0の場合、またはrefreshが呼ばれた場合はデータをリセット
} else {
return [...items, ...data]; // それ以外の場合は、既存のデータに追加
}
});
}
}
これに対して、拡張関数を使えば、「いつデータを読み込むべきか」というロジックを1カ所に集約でき、ページの状態変更はそれに反応するトリガーとしてシンプルに扱えます。状態変化に基づいて自動的にデータが再取得される仕組みがあることで、UIロジックとデータロジックの役割がはっきり分かれ、保守性も高まります。
実践的な使用例:無限スクロール
実際の使用例を見てみましょう。無限スクロールの実装を考えてみます:
@Component({
...
template: `
<ion-content>
<ion-refresher slot="fixed" (ionRefresh)="refresh($event)">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-list>
@for(item of items(); track item.id) {
<ion-item>{{ item.name }}</ion-item>
}
</ion-list>
<ion-infinite-scroll (ionInfinite)="loadMore($event)">
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>
`
})
export class InfiniteScrollComponent {
private http = inject(HttpClient);
page = pageSignal();
items = signal<Item[]>([]);
completeEvent = signal<HTMLIonInfiniteScrollElement | HTMLIonRefresherElement>(undefined);
constructor() {
effect(async () => {
// 初回 / pageの値が変更されたとき、またはrefreshが呼ばれたときに実行
const [page, trigger] = [this.page(), this.page.trigger()];
const data = await firstValueFrom(this.http.get<Item[]>('/api/users/' + page))
.finally(() => this.completeEvent()?.complete()) // CustomEventがあった場合は `complete` を実行
this.items.update(items => {
if (page === 0) {
return [...data]; // ページが0の場合、またはrefreshが呼ばれた場合はデータをリセット
} else {
return [...items, ...data]; // それ以外の場合は、既存のデータに追加
}
});
});
}
refresh(event: RefresherCustomEvent) {
this.page.refresh();
this.#completeEvent.set(event.target);
}
loadMore(event: InfiniteScrollCustomEvent) {
this.page.update(page => page + 1);
this.#completeEvent.set(event.target);
}
}
まとめ
この実装の核となるのは、状態管理の分離と、それに伴う柔軟な制御の実現です。従来の実装では、ページの状態とデータの再読み込みが密結合していました。しかし、この拡張関数を使うことで、page
と trigger
の2つのSignalを使って、ページの状態とデータの再読み込みを完全に分離することができます。
Signalの拡張によって、UIロジックとデータロジックの分離がより簡単になりました。特にリフレッシュや無限スクロールといった、再取得が発生しやすいUIとの相性が良く、今後の開発でも積極的に活用していきたいパターンです。
それでは、また。
Discussion