サービスに置いたsignalの共有範囲を提供位置からレビューする

状態を signal でサービスに持たせる書き方は、記述量が少なく、読み手にも意図が通る。

見た目の上では何も問題がない
@Service()
export class FilterState {
  readonly keyword = signal('');
  readonly page = signal(1);
}

この差分だけを見てレビューを終えられない。signal が決めるのは値の変化がどう伝わるかだけで、この状態を誰と共有するか、いつ捨てるかは書かれていない。それを決めているのは @Service() のほう、つまり提供位置である。

提供位置ごとのインスタンスと破棄の契機

@angular/core 22.2.1 では、提供位置は3通りある。@Service() が @Injectable({ providedIn: 'root' }) と同じ定義を書き込むことはAngular 22の@Service()とprovidedIn: 'root’の混在をどう整理するかで確かめた。ここでは、その選択が共有範囲として何を意味するかを並べる。

提供位置 インスタンス 破棄の契機
@Service() / providedIn: 'root' アプリに1つ アプリの破棄時
コンポーネントの providers そのコンポーネントのインスタンスごとに1つ そのビューの破棄時
ルート定義の providers そのルート設定ごとに1つ 条件付き(次の節)

表の2行目までは DestroyRef の寿命と一致する。注入コンテキストと購読の寿命の関係はAngularの購読と副作用の寿命を注入コンテキストから読むにまとめた。問題は3行目で、ここだけは「画面を離れたら消える」とは限らない。

ルートのprovidersに置いた注入器の寿命

ルート定義に providers を書くと、@angular/router 22.2.1 はルート設定オブジェクトの _injector に注入器を保存する。

22.2.1 の getOrCreateRouteInjectorIfNeeded
function getOrCreateRouteInjectorIfNeeded(route, currentInjector) {
  if (route.providers && !route._injector) {
    route._injector = createEnvironmentInjector(route.providers, currentInjector, `Route: ${route.path}`);
  }
  return route._injector ?? currentInjector;
}

保存先の route は、provideRouter(routes) に渡した配列の要素である。モジュールの先頭で定義した定数がそのまま使われるため、注入器は一度作られるとその定数に付いたまま残る。

破棄する処理は別にある。Router が NavigationEnd を受けたときに呼ばれるが、呼ぶ関数は省略可能な注入で取られている。

22.2.1 の Router(抜粋)
injectorCleanup = inject(ROUTE_INJECTOR_CLEANUP, {
  optional: true
});
// ...
} else if (e instanceof NavigationEnd) {
  this.navigated = true;
  this.injectorCleanup?.(this.routeReuseStrategy, this.routerState, this.config);
}

ROUTE_INJECTOR_CLEANUP を提供するのは withAutoCleanupInjectors() だけである(22.2 で追加され、withExperimentalAutoCleanupInjectors() は同じものへの非推奨の別名になった)。provideRouter(routes) にこの feature を渡していなければ injectorCleanup は null で、?. が何も呼ばない。ルートの providers に置いたサービスは、画面を離れても破棄されず、戻ったときに前回のインスタンスが返る。

feature を渡した場合も、破棄には条件が付く。

22.2.1 の destroyUnusedInjectors(判定部)
const shouldDestroyCurrentRoute = inheritedForceDestroy || !!((route._injector || route._loadedInjector) && !activeRoutes.has(route) && (strategy.shouldDestroyInjector?.(route) ?? false));

strategy は RouteReuseStrategy である。既定の DefaultRouteReuseStrategy は BaseRouteReuseStrategy を継承し、そこの shouldDestroyInjector は true を返す。一方で RouteReuseStrategy の型宣言では shouldDestroyInjector?(route: Route): boolean が省略可能なので、BaseRouteReuseStrategy を継承せずインターフェースだけを満たす独自の戦略を書くと、このメソッドが欠ける。欠けると ?? false に落ち、feature を渡していても破棄されない。タブの状態を保つために独自の戦略を書いたアプリで、この経路が成立する。

つまり「画面単位の状態はルートの providers に置けば遷移で捨てられる」は、22.2 の既定では成り立たない。捨てたいなら、withAutoCleanupInjectors() を渡し、独自の RouteReuseStrategy があれば shouldDestroyInjector を実装したうえで、残るかどうかを実機で確かめる。確かめずに済ませたいなら、画面を担うコンポーネントの providers に置く。こちらはビューの破棄と同時に消える。

Comment
@Reviewer: この `FilterState` をルート定義の `providers` に置いていますが、`provideRouter` に `withAutoCleanupInjectors()` が無いため、注入器は `route._injector` に残り続けます。一覧へ戻ったときに前回の検索条件が復帰する形です。画面ごとに初期化したい想定であれば、`providers` を一覧コンポーネント側へ移してください。

公開を読み取り専用にしたときに残る書き込み

共有範囲が決まったら、次は誰が書けるかである。公開側を asReadonly() に通す書き方が広く使われている。

更新をメソッドに限る形
@Service()
export class CartState {
  private readonly _items = signal<CartItem[]>([]);
  readonly items = this._items.asReadonly();

  add(item: CartItem): void {
    this._items.update((items) => [...items, item]);
  }
}

asReadonly() が返すものは、元の signal を呼ぶだけの関数である。

22.2.1 の signalAsReadonlyFn
function signalAsReadonlyFn() {
  const node = this[SIGNAL];
  if (node.readonlyFn === undefined) {
    const readonlyFn = () => this();
    readonlyFn[SIGNAL] = node;
    node.readonlyFn = readonlyFn;
  }
  return node.readonlyFn;
}

[SIGNAL] は元のノードをそのまま指し、set と update は付けない。依存の追跡は元の signal と同じ経路で行われ、書き込みの口だけが無くなる。isWritableSignal も typeof value.set === 'function' で判定するので、この関数を渡された側からは書けないと分かる。

止まるのはここまでである。readonlyFn は this() の結果をそのまま返すため、呼び出し側が受け取るのは元のオブジェクトと同じ参照である。

読み取り専用を通しても届く変更
const items = this.cart.items();
items[0].quantity = 99; // 要素そのものを書き換えている

WritableSignal.asReadonly() の JSDoc も「読み取り専用の signal は値の深い変更を防ぐ仕組みを持たない」と断っている。通知も走らないので、画面は古い数量を出したまま、配列の中身だけが変わる。公開する値が配列やオブジェクトなら、asReadonly() を付けただけでは所有者が1つに決まらない。保つなら、update の中で新しい配列を作る規約を守るか、Readonly<T> 系の型で受け手側の書き込みを型エラーにする。

Comment
@Reviewer: `items` は `asReadonly()` を通していますが、返るのは同じ配列の参照なので要素の書き換えは通ってしまいます。`CartItem[]` ではなく `readonly CartItem[]` を公開する型にして、呼び出し側の代入をコンパイルで止めてください。

連続したsetの中間状態を読むもの

更新メソッドが複数の signal を順に書く形は、状態がサービスに分かれているときに出てくる。

2つのsignalを順に書く
applyFilter(keyword: string): void {
  this._keyword.set(keyword);
  this._page.set(1);
}

ここで「1行目のあと、keyword が新しく page が古い状態が読まれる」と言えるかどうかは、読む側が何かで分かれる。

effect は読まない。effect の実行は EffectScheduler のキューを経由する。

22.2.1 の ZoneAwareEffectScheduler(抜粋)
add(handle) {
  this.enqueue(handle);
  this.schedule(handle);
}
schedule(handle) {
  if (!handle.dirty) {
    return;
  }
  this.dirtyEffectCount++;
}

set の時点で行われるのはキューへの登録と計数だけで、本体は flush() が呼ばれるまで走らない。flush() は変更検知の中で呼ばれるため、2つの set は1回の実行にまとまる。effect が見るのは keyword と page の両方が新しい状態である。これは toObservable 経由の購読にも当てはまる。内部が effect なので同じキューに乗る。

読むのは、2つの set の間で computed を同期的に呼んだ場合である。computed は読まれた時点で再計算するので、そのときの値の組で計算される。

中間状態で計算される経路
applyFilter(keyword: string): void {
  this._keyword.set(keyword);
  this.logQuery(this.queryString()); // keyword は新しく page は古い
  this._page.set(1);
}

queryString() はここで一度 page: 1 ではない値を返し、logQuery にその値が渡る。page を書いたあとに再度読めば正しい値になるが、記録に残ったものは直らない。

したがってレビューで見るのは「複数の signal を順に書いているか」ではなく、その2行の間に computed の読み取りや同期的な関数呼び出しが挟まっているかである。挟まっていなければ、外から中間状態は観測できない。挟まっているなら、更新をひとまとめにできる形へ寄せる。状態が互いに依存するなら、2つの signal を1つのオブジェクトの signal にするか、page を keyword から導く linkedSignal にする。後者の契機の決まり方はAngularのlinkedSignalがeffectによるリセット実装を置き換える場面で扱った。

リセット手段をどこに置くか

破棄されないインスタンスに状態を持たせると、初期状態へ戻す責務がサービスの外に出る。root 提供のサービスで画面ごとに初期化する実装は、呼び忘れたときに前の画面の値が見える形になる。

リセットをサービス側に持つ
@Service()
export class FilterState {
  private readonly _keyword = signal('');
  private readonly _page = signal(1);
  readonly keyword = this._keyword.asReadonly();
  readonly page = this._page.asReadonly();

  reset(): void {
    this._keyword.set('');
    this._page.set(1);
  }
}

この形でも、初期値が2箇所(フィールドの初期化子と reset)に書かれる。1箇所に寄せるなら、状態を1つのオブジェクトにして定数から復元する。

初期値を定数に寄せる
const initialFilter = { keyword: '', page: 1 } as const;

@Service()
export class FilterState {
  private readonly _state = signal<Filter>({ ...initialFilter });
  readonly state = this._state.asReadonly();

  reset(): void {
    this._state.set({ ...initialFilter });
  }
}

状態の項目が増え、更新の種類も増えてきたなら、素の signal の集合では更新経路が追えなくなる。withState と withMethods に移すかどうかの判断はSignalStoreのwithMethodsがAction層の代わりに失うものにある。

レビュー観点チェックリスト

共有範囲と提供位置の対応を確かめる項目
  • そのサービスの提供位置が、状態を共有してほしい範囲と一致しているか
  • ルート定義の providers に置いた状態を「遷移で捨てられる」前提で設計していないか
  • provideRouter に withAutoCleanupInjectors() があるか(無ければルートの注入器は残る)
  • 独自の RouteReuseStrategy がある場合、shouldDestroyInjector を実装しているか
  • 画面を離れて戻ったときの表示を、実機で一度確かめているか
  • 公開する signal が asReadonly() を通っているか、かつ値が配列やオブジェクトなら型でも書き込みを止めているか
  • 複数の signal を順に書くメソッドの、その間に computed の読み取りや同期的な呼び出しが挟まっていないか
  • 破棄されない提供位置を選んだ場合、初期状態へ戻す手段がサービス側にあるか
  • 初期値がフィールドの初期化子とリセット処理の2箇所に重複していないか
  • 同じ状態を2つのサービスが持ち、effect で突き合わせていないか

おわりに

signal をサービスに置いた差分からは、共有範囲も寿命も読み取れない。読み取れるのは提供位置で、root とコンポーネントの providers は DestroyRef の寿命と一致する。ルート定義の providers だけは別で、22.2 の既定では注入器が破棄されず、withAutoCleanupInjectors() と shouldDestroyInjector の両方が揃ったときに初めて捨てられる。

公開側の asReadonly() が止めるのは set と update の呼び出しだけである。返る参照は元の値と同じなので、配列やオブジェクトを公開するなら型の側でも書き込みを閉じる。

連続した set の中間状態は、effect からは見えない。EffectScheduler のキューに入って1回にまとまるためである。見えるのは2つの set の間に computed の読み取りを挟んだ場合に限られるので、レビューではその行間を読む。

サービス設計の観点の全体はAngular 22のSignalと非同期状態をレビューする100観点の「Signal を共有するサービス設計」の群に並べてある。