🎃

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 の値をセットしても変更がない時でも effectcomputed が追跡できるように trigger Signalを用意しています。また、 pagetrigger を同時に更新できるように 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);
  }
}

まとめ

この実装の核となるのは、状態管理の分離と、それに伴う柔軟な制御の実現です。従来の実装では、ページの状態とデータの再読み込みが密結合していました。しかし、この拡張関数を使うことで、pagetrigger の2つのSignalを使って、ページの状態とデータの再読み込みを完全に分離することができます。

Signalの拡張によって、UIロジックとデータロジックの分離がより簡単になりました。特にリフレッシュや無限スクロールといった、再取得が発生しやすいUIとの相性が良く、今後の開発でも積極的に活用していきたいパターンです。

それでは、また。

Discussion