Marginalia

Angular v19: effect() の変更点

Angular v18では開発者プレビューとして公開されていたeffect()関数の仕様は、コミュニティからのフィードバックを受けてv19で仕様を変更することになった。この記事ではそのv18からv19にかけてeffect()関数について変わることをまとめる。

Angularチームの公式ブログにも記事が投稿されているので、そちらをすでに読んでいれば内容は大きく変わらない。

これはAngularの破壊的変更ではない

念の為に最初に書いておくが、そもそもeffect()関数は開発者プレビューとして公開されているものであり、一度もAngularの安定APIになったことはない。Angularの開発ポリシーでは安定APIについてのルールが定められているが、開発者プレビューはその枠の外にある。

このドキュメントに記載されているポリシーと慣行は、開発者プレビューとしてマークされたAPIには適用されません。このようなAPIは、フレームワークの新しいパッチバージョンでも、いつでも変更される可能性があります。チームは、開発者プレビューAPIを使用する利点が、セマンティックバージョンの通常の使用方法ではない破壊的変更のリスクに見合うかどうかを自力で判断する必要があります。

したがって、effect()関数の仕様は互換性なく変更されるが、元の振る舞いと共存する非推奨期間などはなく、v19.0.0のリリースで完全に切り替わる。開発者プレビューのAPIを利用するということは、こういった変更が入ることを受け入れることとセットである。リスクはあるが、開発者プレビューのAPIを早く取り入れてフィードバックを送ることで、より使いやすいものに洗練させるコントリビューションのチャンスでもある。今回の変更は、まさに人柱としてeffect()関数を使い倒した先行者たちの貢献の結果である。

大きな変更点

effect()関数の振る舞いに大きな変更点が2つある。ひとつはeffect()関数のコールバック中で別のSignalへの同期的書き込みが許可されるようになったこと。もうひとつはAngularのコンポーネント・ディレクティブの中で作られたEffectが変更検知の中で実行されるようになったことだ。

副作用としてのSignalへの同期的書き込みの許可

v18まではeffect()関数内でSignalへ同期的な書き込みをすることはデフォルトで禁止されていた。

const val = signal(0);

effect(() => {
  val.set(1); // => error!!
});

これが禁止されている理由は大きく2つある。ひとつは無限ループを防ぐためである。Effectはそのコールバック関数内で読み取っているSignalの変更でトリガーされるため、読み取りと書き込みが一緒に行われると自分自身をトリガーしつづけてしまうおそれがあった。もうひとつは、Angularチームの設計思想において、effect()の内部でSignalへ同期的に書き込みをするケースのほとんどはcomputed()で代替可能であり、そのほうが好ましいという理由だ。AngularチームのテクニカルリードであるAlex Rickabaughが解説している動画がわかりやすい。

というわけで、Signalへの同期的書き込みは非推奨とされているが、開発者の責任で明示的に許可することはできた。allowSignalWritesフラグを有効にするか、untracked()関数でラップするかどちらかによって、同期的な値の書き込みができた。

const val = signal(0);

effect(() => {
  val.set(1); // => ok
}, { allowSignalWrites: true } );

effect(() => {
  untracked(() => {
    val.set(1); // => ok
  });
};

しかし、この制約はAngularチームの当初の意図通りには機能せず、開発者にとって不要なコストとなっていたとして、v19ではこの制約は撤廃された。今後は特に何もしなくても同期的な書き込みができるようになった。

とはいえ、computed()に置き換え可能な場合はそうすべきであるというAngularチームの考えは変わっておらず、effect()をなるべく使わずにすむようリアクティブプログラミングを支援する新たなヘルパーAPIを今後実装していくようだ。ngxtensionのderivedAsyncなどすでにコミュニティで一定のニーズがわかっているものは公式に取り込まれるかもしれない。

Effectの呼び出しタイミングの変更

v18までは、effect()関数で作成されたすべてのEffectは、Angularアプリケーション内でグローバルな単一のマイクロタスクキューで実行タイミングが管理されていた。v19からはEffectがどのように作成されたかによって2つの動作に分かれる。Effectがコンポーネントツリーの中で作成された場合は、そのEffectの実行タイミングを握るのはそのコンポーネントの変更検知となる。コンポーネントツリーの外で作られた場合はこれまでと同じくアプリケーショングローバルなタスクキューで処理される。これらは内部的には “component effect (view effect)” と “root effect” と呼び分けられる。

これにより何が変わるかというと、まずはコンポーネントの変更検知処理と連動することで、Effectの中でコンポーネントのインプットやビュークエリの結果などへのアクセスが安全になる。これまでは変更検知の処理とグローバルのタスクキューの実行順序は不安定だったため、コンポーネントがインプットを初期化するより先に動いてしまったり、逆に遅すぎたりと問題が多かった。ライフサイクルメソッドと同じ実行基盤に乗ったことでEffectがより安全に使えるようになる。

また、Effectの実行順序がコンポーネントの親子関係に影響を受けるようになる。ngOnInitなどのライフサイクルメソッドと同じく、まず親のEffectが処理され、その後に子のEffectが処理されるようになる。親コンポーネントのEffectによりテンプレートの状態が更新され、子コンポーネントのEffectがトリガーされる、というように変更検知と整合性をとって実行順序が決定される。これによって予期せぬエラーが起きにくくなる。

バージョンアップへの備え

以上の2点がv19におけるeffect()関数の仕様変更である。実行のスケジューリングに関わる内部の根幹部分が書き直されているが、APIそのもののインターフェースは変わらないため、v18までに書かれたコードは少なくともビルドエラーになることはないだろう。

問題になるのは、Effectの実行順序に依存したコードを書いていた場合のランタイムエラーである。公式ブログの記事によれば、Google社内のコードベースでこの変更をテストしたところ、タイミングの変更がコードに影響を与えたのは約100のケースで、そのうちの約半数はテストのみの修正で済んだらしい。また、いくつかのケースでは実装を修正することでより正しいアプリケーションの動作につながったようだ。

v19が正式リリースされるまでにはまだ時間があるが、v18のうちにやっておけることがあるとすれば、まずはeffect()の使用箇所を特定しておくことだろう。その中で、コンポーネントのライフサイクルやレンダリングのタイミングなどに依存していそうな怪しいものがあれば、アプリケーションのデプロイ前に問題を発見できるように、なるべくテストを書いて準備しておこう。そして、v19のRCバージョンがリリースされたタイミングでテストを実行して問題が起きないかを試しておくことだ。

また、振る舞いが変わることがわかった今のタイミングでは新たにeffect()を使うのはやめておき、v19にアップグレードしてから使うように我慢しておくのもいいだろう。