Angular 22のSignalと非同期状態をレビューする100観点
Angular 22 で Resource 系が stable になり、
paramsが(ctx) => Rのシグネチャに変わった。effectのallowSignalWritesは非推奨になり、書き込みの許可はレビューの論点から外れた。「loading フラグを自分で持っているか」「allowSignalWritesを付けたか」を問うリストは、問いの前提のほうが古くなっている。ここでは 22.2 系を前提に、signal と非同期状態のレビュー観点を100項目へ整理する。
対象は @angular/core 22.2.1 の signal / computed / linkedSignal / effect / resource、@angular/core/rxjs-interop の toSignal / toObservable / rxResource、@angular/common/http の httpResource である。コンポーネントの入出力や変更検知そのものはAngular 22のコンポーネント設計をレビュー視点で整理する100観点に分けてある。ストア(NgRx SignalStore、NgRx Store)と、Operator による競合制御も本リストの範囲には入れていない。
各群は「群の問い」から始まる。差分を読みながらその問いに yes か no で答えられるかを確かめ、答えられない項目をレビューコメントへ変換する、という順で使う想定である。
22 で前提が変わり、旧来の指摘がそのままでは通らなくなった箇所を先に挙げる。
| 箇所 | 21 までの前提 | 22.2 の前提 |
|---|---|---|
effect の書き込み |
allowSignalWrites: true が要る |
既定で許可。同オプションは非推奨 |
| 非同期の状態 | loading / error を手で持つ |
resource の status が6値を返す |
resource の依存 |
params: () => R(引数なし) |
params: (ctx) => R。ctx.chain() で連鎖 |
| Resource の成熟度 | experimental | stable(debounced() だけ experimental のまま) |
| 派生の上書き | 素の signal と effect で組む |
linkedSignal。22.1 で set オプションが入った |
| HTTP の取得 | HttpClient と signal を手で繋ぐ |
httpResource |
✅ signal の生成と所有(01〜10)
この signal を書き換える主体が一つに決まっているかを問う。
signal を再代入できる変数と見ると、この群はほぼ指摘できなくなる。書き手が複数いる状態は型でもコンパイルでも検出されず、どの更新が最後に残るかが呼び出し順序で決まる。
03 と 05 の根拠は等価判定にある。signal の変更通知は equal を通り、既定は Object.is である。配列の要素を push で足しても参照は同じままなので、通知は起きず、読み手の computed も再評価されない。型は配列のままなのでコンパイルは通り、表示だけが止まる。
✅ computed の純粋性(11〜20)
computed の本体が、signal の読み取り以外のことをしていないかを問う。
computed を値のキャッシュ関数と見ると、副作用を入れたときに何が壊れるかが見えない。computed は依存グラフの節点であり、評価のたびに依存集合が決め直される。
13 が落とし穴になるのは、依存が静的な宣言ではなく実行の記録だからである。computed は読まれたときに評価され、その評価の中で実際に読んだ signal だけが依存として残る。a() ? b() : c() と書いた computed は、a() が true の間 c に依存しない。c だけを変えても再評価は起きず、意図した更新なのか抜けなのかはコードからは判別できない。
readonly label = computed(() =>
this.useShortName() ? this.shortName() : this.fullName()
);
// useShortName が true の間、fullName の変更では再評価されない12 を落としたときの壊れ方は 11 とは別である。computed は読まれたときにだけ評価され、読み手が消えれば評価も止まる。この性質の上に HTTP を置くと、送信の回数が「誰がいつその値を読んだか」で決まり、テンプレートの書き換えだけで通信量が変わる。
✅ linkedSignal による上書きとリセット(21〜30)
「普段は派生、ユーザー操作では上書き」という形が linkedSignal で表現されているかを問う。
linkedSignal を「書き込める computed」と説明すると、肝心の破棄の挙動が落ちる。source が変われば、書き込んだ値は computation の結果で置き換えられる。
21 と 27 が指すのは同じ再発明である。effect で「source を読んだら選択を初期化する」と書くと、初期化の実行が変更検知のタイミングに乗るため、source が変わった直後の一瞬だけ古い選択が読める。linkedSignal は source の変化を検知した時点で computation を再実行して値を置き換えるので、この中間状態が残らない。
25 は 22.1 で追加された set オプションに固有の落とし穴である。set を指定すると書き込みの経路を自分で書くことになり、rawSet を呼ばない限りローカルの値は変わらない。source への書き戻しだけを実装して rawSet を忘れると、source が反映されるまでの間、画面上の選択が元に戻って見える。
✅ effect の置きどころ(31〜40)
effect が、signal で表せない外側との接点だけに使われているかを問う。
22 では allowSignalWrites が非推奨になり、書き込みは既定で許される。許可の有無はレビューの論点ではなくなった。残る論点は、その更新を effect に置く設計が妥当かどうかである。
33 が追いにくい不具合になる筋道は、実行の順序を誰も宣言していない点を通る。effect は依存した signal が変わるたびに再実行される。ある effect が書いた signal を別の effect が読むと、二つの実行順序はスケジューラの都合で決まり、コードのどこにも書かれない。片方の条件を一つ足しただけで順序が入れ替わり、再現しない不具合として現れる。
38 と 32 は、置き換え先が違う。取得が params の変化で決まるなら resource に移せる。派生が同期的に計算できるなら computed、上書きを許したいなら linkedSignal に移せる。どれにも当てはまらず effect が残る場面は、DOM 操作、ストレージ書き込み、外部ライブラリへの通知などである。effect と computed の線引きはeffectとcomputedの境界で扱っている。
✅ 注入コンテキストと寿命(41〜50)
反応グラフの節点が、それを必要とする対象と同じ寿命で作られているかを問う。
inject() も effect() もクラスの中なら好きな場所で呼べる、という理解は 22 でも誤りである。注入コンテキストはコンストラクタとフィールド初期化子に限られ、それ以外では明示的な injector が要る。
44 の機構は後片付けの登録先にある。注入コンテキストで作られた effect は、そのコンテキストの DestroyRef へ破棄処理を登録する。providedIn: 'root'(22 の書き方では @Service())のサービスで作った effect の登録先はルート injector であり、アプリが終わるまで破棄されない。画面を離れても購読とタイマーが動き続け、しかもテストでは1画面しか開かないため気付きにくい。寿命の扱いは注入コンテキストと寿命に個別の解説がある。
48 が危ういのは、ガードとインターセプターが呼び出しのたびに実行されるためである。実行ごとに effect を作れば、遷移の回数だけ節点が増え、どれも破棄されない。
✅ resource() の読み取り契約(51〜60)
6つある status のうち、どれを扱い、どれを扱わないと決めたかを問う。
isLoading() が false なら値がある、という読み方はこの群で最も多い誤りである。idle でも error でも false になる。
| status | 意味 | value() が返すもの |
|---|---|---|
idle |
params が undefined を返し、取得していない |
defaultValue(未指定なら undefined) |
loading |
初回の取得中 | 同上 |
reloading |
値を保ったまま再取得中 | 前回の値 |
error |
取得に失敗した | defaultValue。error() に理由が入る |
resolved |
取得に成功した | 取得した値 |
local |
set() / update() でローカルに書いた |
書いた値 |
52 を落とすと、再取得の最中に古い値が最新として表示される。reloading は前回の値を保ったまま取得をやり直している状態であり、value() は旧値を返す。resolved と reloading を同じ分岐にまとめた実装は、更新ボタンを押しても表示が変わらないまま新しい値に差し替わる。利用者からは押下が無視されたように見える。
56 は 22 で書き方が変わった箇所である。params は ctx を受け取り、ctx.chain(other) で別の Resource の値を読める。相手が resolved か local でなければ、内部で専用の値が throw されて自分の status に伝播する。effect で待ち合わせる実装は、この伝播を自前の分岐で再現することになり、相手の error を自分の error として扱う処理が抜けやすい。
readonly user = resource({
params: () => ({ id: this.userId() }),
loader: ({ params, abortSignal }) => fetchUser(params.id, abortSignal),
});
readonly orders = resource({
params: (ctx) => ({ userId: ctx.chain(this.user).id }), // user が resolved になるまで自分も loading
loader: ({ params, abortSignal }) => fetchOrders(params.userId, abortSignal),
});54 が見分けを潰す理屈は単純である。defaultValue: [] を指定した Resource は、失敗しても空配列を返す。value() だけを見る画面は「0件です」と表示し、通信の失敗が利用者にも開発者にも伝わらない。失敗時の扱いはresourceのエラー契約で個別に扱っている。
✅ rxResource と httpResource(61〜70)
Observable と HTTP を Resource に載せたとき、解除と再試行の責務がどちらにあるかを問う。
httpResource を HttpClient の薄い別名と見ると、購読の開始と解除を Resource の側が握る点が落ちる。取得を止める条件も再取得の契機も、params の側に移っている。
62 で問題になるのは中断時の整合性である。rxResource は params が変わるたびに前の購読を解除して新しい購読を張る。complete しない Observable でも解除そのものは行われるが、解除の時点で途中まで進んだ副作用(部分的な書き込み、開いたままの接続)は Resource の側では戻せない。副作用を持つストリームを loader に渡すなら、中断後の状態を自分で決めておく必要がある。
66 が止まらなくなる理由は、effect の依存に失敗そのものが入る点にある。status() が error になったのを effect で読んで reload() を呼ぶと、再試行がまた失敗したときに同じ effect が再実行される。回数の上限も待ち時間もこの構造には含まれないので、サーバーが落ちている間は要求を送り続ける。
65 は 22 で provideHttpClient() が Fetch 既定になったことと関係する。abortSignal を loader から下流へ渡していれば、params の変化による中断が AbortController 経由で実際の要求の取り消しまで届く。渡していなければ、Angular 側は結果を捨てるが要求は走り切る。
✅ toSignal と toObservable(71〜80)
RxJS と signal の境界が1箇所に寄っているかを問う。
toSignal を挟めば RxJS の寿命問題は消える、という理解は条件付きでしか正しくない。manualCleanup: true を付けた時点で自動の解除は外れる。
74 の機構は登録の有無にある。toSignal は既定で現在の DestroyRef に解除を登録するため、コンポーネントの破棄とともに購読が切れる。manualCleanup: true はこの登録を外す指定であり、代わりの解除経路を書かなければ購読は残る。アプリ全体で共有するストリームに付ける指定なので、コンポーネント内に残っていれば指摘の対象になる。
71 と 79 は、変換の往復が競合制御を壊す点で繋がっている。toSignal は最新の1値だけを保つので、途中の発行は signal からは見えない。この性質の上で switchMap 相当の打ち切りを signal 側に書き直すと、打ち切るべき古い応答が到着順によっては後から上書きしてしまう。境界の引き方はSignalとrxjs-interopの契約に個別の解説がある。
✅ Signal を共有するサービス設計(81〜90)
この signal を共有する範囲が、サービスの提供位置と一致しているかを問う。
サービスに signal を置けば状態管理になる、とは言えない。所有者と更新経路が決まっていなければ、どこからでも書ける可変変数が置き場所を変えただけになる。
83 が中間状態を見せる筋道は、更新が同期的に伝わることから出る。this.items.set(next); this.total.set(sum(next)); と二つの signal を順に書くと、一行目の時点で items に依存した computed の結果は無効になる。その間に effect が走れば、新しい items と古い total の組を読む。複数の値をまとめて更新するなら、1つのオブジェクトを持つ signal にするか、派生側を computed にして二重の保持をやめる。
81 は 22 の記法変更に関わる。@Service() は @Injectable({ providedIn: 'root' }) の短縮形であり、書き換えで provide 範囲が変わるわけではない。短い記法になったことで、画面専用の状態までルート提供のサービスへ置く変更が通りやすくなっている。84 はその結果を捕まえる項目である。
✅ テストと検証(91〜100)
反応グラフの振る舞いが、テストで固定されているかを問う。
signal を set すれば effect はすぐ走る、という前提でテストを書くと、通ったり落ちたりするテストができる。effect の実行は変更検知のタイミングに乗る。
91 の背景はスケジューラである。effect は signal の変更を検知したあと、変更検知の実行に合わせて走る。set の直後に同期で走る保証はないため、set して即 expect するテストは、たまたま先に走ったときだけ通る。実行の機会を明示的に与えてから検証する。
94 を全部通すのが難しいのは、idle と local が意図的にしか作れないからである。idle は params が undefined を返したとき、local は set() / update() で書いたときにだけ現れる。この2つを通らないテストは、画面が6値のうち4値しか想定していないことと等しい。実装側で分岐を書いていても、残り2値の分岐が正しいかは確かめられていない。
NgRx SignalStore と NgRx Store の設計、RxJS の購読の寿命と Operator による競合制御は、ここに含めていない。4系統はそれぞれ別の API 契約を持ち、1本に押し込めば1系統あたり十数項目にしかならないためである。コンポーネントとテンプレートの観点はAngular 22のコンポーネント設計をレビュー視点で整理する100観点にある。個別論点の解説はng状態管理にまとめてある。