@ngrx/signals/resourceの拡張で再取得中と失敗時の表示をどう決めるか
@ngrx/signals/resourceの拡張で再取得中と失敗時の表示をどう決めるか
Angular の Resource は、再取得の間 value() が undefined に戻り、失敗すると value() が例外を投げる。この2つの既定を変える部品が @ngrx/signals/resource にある。
extendResource / provideResourceExtensions
withPreviousValueOnLoading / withValueOnLoading
withPreviousValueOnError / withValueOnError
RESOURCE_EXTENSIONS / LOADING_EXTENSION_TYPE / ERROR_EXTENSION_TYPE全部に @experimental が付いている。採用する判断にはそれだけでも条件が付くが、ここで扱うのは別の点である。この拡張は Resource の読み出しを差し替えるため、value() と hasValue() と status() が別々の事実を指す状態を作れる。
拡張が適用される仕組み
extendResource は Resource を包まない。受け取ったオブジェクトのプロパティを書き換えて、同じ参照を返す。
function extendResource(resource, ...extensions) {
const providedExtensions = inject(RESOURCE_EXTENSIONS, { optional: true }) ?? [];
const allExtensions = [...providedExtensions, ...extensions];
const uniqueExtensions = new Map(allExtensions.map((ext) => [ext.type, ext])).values();
for (const extension of uniqueExtensions) {
extension.apply(resource);
}
return resource;
}各拡張の apply は Object.defineProperty(resource, 'value', { value: new Proxy(resource.value, { apply(...) {...} }) }) の形で読み出しを差し替える。元の value は Proxy の対象として内側に残るので、2つ適用すれば入れ子になる。差し替え後も signal として扱える(isSignal が真を返し、set も残る)が、Object.is で比べると元の参照とは別物である。
inject を呼ぶ関数なので、注入コンテキストの外では動かない。
5) 注入コンテキスト外の extendResource: NG0203: The `InjectionToken RESOURCE_EXTENSIONS` token injection failed.ストアのメソッドの中で Resource を作り直す設計では、extendResource を呼ぶ位置がフィールドの初期化子か runInInjectionContext の中かを確かめることになる。
重複の排除は type で行う。new Map に同じキーで入れると後の値が勝ち、位置は最初に出てきた場所に残る。withValueOnLoading と withPreviousValueOnLoading は同じ LOADING_EXTENSION_TYPE を、withValueOnError と withPreviousValueOnError は同じ ERROR_EXTENSION_TYPE を持つので、同じ系統を2つ渡しても、適用されるのは1つだけである。
16) 同じ type を2つ渡したときの loading 中の値: status=loading value=["second"] hasValue=trueprovideResourceExtensions で登録した分は引数より前に並ぶため、アプリ全体に置いた既定を、呼び出し側の引数で上書きできる。
3) provideResourceExtensions だけ(loading 中): status=loading value=["G"] hasValue=true
4) グローバルと引数で同じ type(loading 中): status=loading value=["L"] hasValue=truehasValue()が指す内容の変化
4つの拡張のうち、hasValue を差し替えるのはエラー側の2つだけである。それでも読み出しの結果は4つとも変わる。
isValueDefined = computed(() => {
if (this.isError()) {
return false;
}
return this.value() !== undefined;
});
hasValue() {
return this.isValueDefined();
}hasValue() は this.value() を読む。value プロパティが差し替わっていれば、この computed も差し替え後の値を読む。したがって loading 側の拡張を入れただけで hasValue() の答えが変わる。
1) 素の resource / 初回 loading 中 : status=loading value=undefined hasValue=false
9) withValueOnLoading / 初回 loading 中: status=loading value=[] hasValue=trueテンプレートの @if (r.hasValue()) は、取得が終わっていない時点でも真になる。表示したいものが「取得済みの値」なら、分岐の条件は status() か isLoading() に移す。拡張は status に手を入れないので、状態の判定はそのまま使える。
@Reviewer: `withValueOnLoading([])` を入れたので、`@if (resource.hasValue())` の分岐は初回の取得中にも真になります。`hasValue()` は `value()` を読む computed で、差し替えた読み出しの結果を見るためです。空の一覧と取得中の表示を分けるなら、`@if (resource.isLoading())` を先に置いてください。前の値を覚える時機
withPreviousValueOnLoading と withPreviousValueOnError は、閉じ込めた変数に前の値を持つ。更新はプロパティを読んだときだけ起きる。
let value = resource.hasValue() ? resource.value() : undefined;
Object.defineProperty(resource, 'value', {
value: new Proxy(resource.value, {
apply(target, thisArg, args) {
try {
const result = Reflect.apply(target, thisArg, args);
if (!untracked(resource.isLoading)) {
value = result;
}
return result;
} catch (error) {
if (untracked(resource.status) === 'error') {
return value;
}
throw error;
}
},
}),
...
});value が書き換わるのは apply の中、つまり誰かが resource.value() を呼んだときである。解決してから失敗するまでの間に一度も読まれなければ、覚えているのは apply 時点の値のままになる。
1) 解決後に value() を読んでから失敗: status=error value=["a"] hasValue=true (読んだ値は ["a"])
2) 解決後に一度も読まずに失敗: status=error value=["G"] hasValue=true2行目の ["G"] は、アプリ全体に置いた withValueOnLoading(['G']) の代替値である。withPreviousValueOnError の apply が走った時点で Resource は loading だったため、resource.hasValue() が真を返し、loading 用の代替値が「前の値」として記録された。表示していない Resource、@defer の中にある Resource、条件分岐で読まれない Resource では、「前の値」が期待と違う。
@if (r.hasValue()) { ... } の形で値を読んでいる画面なら、解決時に一度は読まれるため意図どおりに動く。読み出しが特定の分岐にしか無い構成では、失敗時に何が出るかをレビューで追う。
2つの拡張を重ねたときの順序
loading 側と error 側を1つずつ渡すと、両方が value を差し替える。あとから適用した側が外側になる。
14) error→loading の順 / 失敗時: status=error value=["E"] hasValue=true
15) error→loading の順 / その次の再取得中: status=loading value=["E"] hasValue=true
14) loading→error の順 / 失敗時: status=error value=["E"] hasValue=true
15) loading→error の順 / その次の再取得中: status=loading value=undefined hasValue=false失敗した時点では同じ値を返すが、その次の再取得中が違う。withValueOnError を先に適用した場合、外側の withPreviousValueOnLoading は失敗中の読み出し(isLoading が偽)で代替値 ['E'] を受け取り、それを前の値として覚える。逆順では外側がエラー側になり、内側の loading 用 Proxy は Reflect.apply が例外で抜けるため代入に到達せず、前の値は undefined のまま残る。
失敗の次に出る表示が、直前に成功した値ではなく失敗時の代替値になる経路がある。どちらを出すかは画面の要件で決まるが、引数の順序で決まる仕様ではないほうが読みやすい。重ねるなら、何が出るかをテストで固定する位置にある。
依存の登録が止まる経路
withPreviousValueOnLoading の Proxy は、loading 中に元の signal を読まない。
apply(target, thisArg, args) {
if (!untracked(resource.isLoading)) {
value = Reflect.apply(target, thisArg, args);
}
return value;
}isLoading の読み出しは untracked で囲まれ、元の value も呼ばれない。この読み出しを computed の中で行うと、依存が1つも登録されない。
17) loading 中に computed を読む: undefined count= 1
18) 解決後に同じ computed を読む: undefined count= 1
19) 素の resource で同じことをする: undefined count= 1
20) 解決後: ["a"] count= 2拡張を入れた側(17・18)は、取得が終わっても再計算されない。素の Resource(19・20)は value() が元の computed を読むので、解決後に再計算される。
hasValue() が回復するのは、isValueDefined が this.isError() も読んでいて、そちらで status に依存が付くからである。自分で書く派生に同じ保険は無い。computed(() => r.value()) のように value() だけを読む派生は、初回の取得中に読まれた時点で止まる。
テンプレートの中で r.value() を直接読む形なら、ほかの signal の更新でビューが再評価されたときに読み直される。止まるのは computed と linkedSignal、つまりキャッシュを持つ派生である。派生の選び方そのものはAngularのeffectを状態同期に使うコードをレビューで止めるで扱った。
@Reviewer: `withPreviousValueOnLoading()` を入れた Resource を `computed(() => this.todos.value())` で包んでいます。この拡張は loading 中に元の signal を読まず `untracked` だけで答えるため、取得中に一度読まれた派生は依存を持たず、解決しても再計算されません。派生の中で `this.todos.status()` を先に読むか、`value()` をテンプレートから直接読む形に変えてください。拡張を使わない選択
再取得中に前の値を出したい要件は、linkedSignal でも書ける。source に Resource の status や取得したキーを置き、computation で解決済みの値だけを残す形である。こちらは読み出しの差し替えではないので、value() と hasValue() の意味は変わらない。
readonly todos = httpResource<Todo[]>(() => `/api/todos?page=${this.page()}`);
readonly shown = linkedSignal<ReadonlyArray<Todo> | undefined, ReadonlyArray<Todo> | undefined>({
source: () => (this.todos.hasValue() ? this.todos.value() : undefined),
computation: (next, prev) => next ?? prev?.value,
});拡張のほうが短く書けるのは確かである。選ぶ基準になるのは、同じ Resource を読む箇所が複数あるかどうかになる。1箇所なら派生を1つ作るほうが挙動が追いやすく、アプリ全体で表示の方針を揃えたいなら provideResourceExtensions のほうが重複しない。linkedSignal のリセットの時機はAngularのlinkedSignalがeffectによるリセット実装を置き換える場面で確かめた。
レビュー観点チェックリスト
@experimentalである前提が、採用の判断に入っているかextendResourceの呼び出しが注入コンテキストの中にあるか- 同じ
typeの拡張を2つ渡していないか(適用されるのは後の1つだけ) provideResourceExtensionsの登録範囲が、上書きしたい範囲と合っているか- loading 側の拡張を入れた Resource で、
hasValue()を「取得済み」の判定に使っていないか - 表示の分岐が
status()かisLoading()を見ているか withPreviousValue*を入れた Resource が、解決時に必ず読まれる位置にあるか- loading 側と error 側を重ねた場合に、失敗の次の再取得で出る値をテストで固定しているか
value()だけを読むcomputedやlinkedSignalを作っていないか- 代替値(
withValueOnErrorの引数)が、失敗を正常系に見せていないか - 失敗を利用者に伝える経路が、値の差し替えとは別にあるか
- 拡張の代わりに
linkedSignalで足りる箇所を、拡張で書いていないか
おわりに
extendResource は Resource を包まず、value と hasValue のプロパティを Proxy で差し替える。差し替えは Object.defineProperty で行われるので、型は変わらず、同じオブジェクトがそのまま使われる。読み出しの意味だけが変わる構造が、レビューで見落とす箇所を作る。
見落としは3つの形で出る。hasValue() が取得中にも真を返すこと、withPreviousValue* が「読まれた時点」の値しか覚えないこと、loading 中の読み出しが依存を登録しないために computed が止まること。いずれも開発モードの警告が無く、型検査も通る。
重ね順の違いは、失敗した次の再取得で表に出る。引数の順序が表示の内容を決める状態は説明しにくいので、同じ Resource に2つ重ねるなら、出る値をテストで固定しておく。
Resource の value() と hasValue() の既定の契約はAngularのResourceでhasValue()を省いた実装をレビューで止めるにある。SignalStore 側の観点の全体はNgRx SignalStoreの状態設計をレビューする100観点の「resource 拡張と非同期」の群に並べてある。