Marginalia

Angular: APP_INITIALIZERとEffectの用法

Signal APIの effect はSignalの値の変更に反応して副作用を実行できる機能だ。サンプルコードではコンポーネントクラスの中で使われているものが多いが、別にコンポーネントと関係ないところで呼び出すこともできるし、なんならクラスのコンストラクタやメソッドである必要もない。インジェクションコンテキストでさえあればどこでもよいことになっている。

https://angular.jp/guide/signals#effect

https://angular.jp/guide/dependency-injection-context

@Component({...})
export class EffectiveCounterCmp {
  readonly count = signal(0);
  constructor() {
    // Register a new effect.
    effect(() => {
      console.log(`The count is: ${this.count()})`);
    });
  }
}

ということで今回、 APP_INITIALIZEReffect を併用してみた。

https://angular.jp/api/core/APP_INITIALIZER#usage-notes

たとえば、アプリケーションのログインユーザー情報を外部のサービス(たとえばGoogle AnalyticsやSentryなど)に送信したいケース。一般化すれば、アプリケーション内部の状態の変化を一方的に外部サービスへ同期したいような場面では、状態の変更を購読する形で実装したい。状態がSignalであれば、次のように副作用として記述できる。

// Sentry.setUserのようなもののイメージ
const analyticsService = {
  setUser: (user: User | null) => {
    if (user) {
      console.log(`set user: ${user.name}`);
    } else {
      console.log(`unset user`);
    }
  },
};

// ユーザー情報が更新されるたびにsetUserする副作用
function bindUserToAnalytics($user: Signal<User | null>) {
  effect(() => {
    const user = $user();
    analyticsService.setUser(user);
  });
}

ではこの effect をどう呼び出すかというと、 APP_INITIALIZER のファクトリー関数で呼び出す。 useFactory 関数のスコープはインジェクションコンテキストである。 inject() が呼び出せるということは effect() も呼び出せる。このようにアプリケーションの初期化のタイミングで副作用を宣言しておけば、アプリケーション側からはまったく関心を向けずにおくことができる。

bootstrapApplication(App, {
  providers: [
    {
      provide: APP_INITIALIZER,
      multi: true,
      useFactory: () => {
        const userAuth = inject(UserAuthService);
        bindUserToAnalytics(userAuth.$currentUser);

        return () => {};
      },
    },
  ],
});

いままではこういうアプリケーション初期化時の宣言的コードは AppComponent に担わせがちだったが、この形のほうがうまく関心を分離できている。

何もしない関数を返しているのが気持ち悪ければ、初期化関数をインジェクションコンテキストにしてしまってもよいが、結果にほとんど違いはない。汎用的にユーティリティ関数を作れば、次のようにまとめられる。

function bindUserToAnalytics($user: Signal<User | null>) {
  effect(() => {
    const user = $user();
    analyticsService.setUser(user);
  });
}

function provideAppInitializer(fn: () => unknown): EnvironmentProviders {
  return makeEnvironmentProviders([
    {
      provide: APP_INITIALIZER,
      multi: true,
      useFactory: () => {
        const envInjector = inject(EnvironmentInjector);
        return () => {
          runInInjectionContext(envInjector, fn);
        };
      },
    },
  ]);
}

bootstrapApplication(App, {
  providers: [
    provideAppInitializer(() => {
      const userAuth = inject(UserAuthService);
      bindUserToAnalytics(userAuth.$currentUser);
    }),
  ],
});

SignalやEffectの可能性はまだまだ未知数で、サンプルコードに固定観念を植え付けられてはいけないということは間違いない。

実際に動くサンプルを貼っておく。