AngularのResourceでhasValue()を省いた実装をレビューで止める
AngularのResourceでhasValue()を省いた実装をレビューで止める
httpResource() や rxResource() は、通信の状態を Signal として扱えるようにする。value()、isLoading()、error() が揃っているので、テンプレートから直接読める。
読めるようになったぶん、エラー時の契約を確認しないまま使われやすい。Angular 22 の型定義には、value について次のように書かれている。
The current value of the
Resource, or throws an error if the resource is in an error state.
エラー状態で value() を読むと、undefined が返るのではなく例外が投げられる。
テンプレートから直接読んでいる実装
export class UserListComponent {
private readonly userApi = inject(UserApi);
readonly users = httpResource<User[]>(() => '/api/users', {
defaultValue: [],
});
}@if (users.isLoading()) {
<app-spinner />
} @else {
<ul>
@for (user of users.value(); track user.id) {
<li></li>
}
</ul>
}defaultValue: [] を指定してあるので、値が無いときは空配列が返るように見える。実際、読み込み中はそのとおりになる。
通信が失敗した場合は違う。isLoading() は false に戻るため @else の側に入り、そこで users.value() が評価されて例外が投げられる。描画の途中で失敗するため、画面には何も出ない。
defaultValue はエラー時の代替値を設定する機能ではない。パラメータの変更で読み込みが始まったときに戻る値であって、エラー状態には関与しない。
@Reviewer: `error` 状態で `value()` を読むと例外が投げられます。`defaultValue` はエラー時の代替値ではないので、`hasValue()` で分岐してから読んでください。hasValue による分岐
利用できる値があるかどうかは hasValue() で確認する。この関数はリアクティブなので、テンプレートから呼べば状態の変化に追従する。
@if (users.isLoading()) {
<app-spinner />
} @else if (users.error()) {
<app-error [error]="users.error()" />
} @else if (users.hasValue()) {
<ul>
@for (user of users.value(); track user.id) {
<li></li>
}
</ul>
}error() を先に見る形にすると、失敗したことを画面に出せる。hasValue() だけで分岐しても例外は避けられるが、その場合は失敗が「値が無い」状態と区別されないまま画面から消える。
どちらの分岐を先に置くかは、失敗を利用者に伝える必要があるかどうかで決める。
読み込み中に値が消える場合と残る場合
もうひとつ混乱しやすいのが、再読み込み中の値の扱いである。
パラメータが変わって読み込みが始まった場合、value() は既定値へ戻る。defaultValue を指定していなければ undefined になる。一方、reload() で読み直した場合は以前の値が保持される。
同じ「読み込み中」でも、前の内容が画面に残る場合と消える場合がある。一覧を再取得するたびに画面がちらつく、という指摘の原因がここにあることが多い。
@Reviewer: パラメータ変更による再読み込みでは `value()` が既定値へ戻るため、一覧が一度空になります。前の内容を残したまま更新したい要件であれば、`reload()` を使う形にできませんか。stream が完了するときの条件
rxResource() を使う場合、stream に渡す Observable には制約がある。値かエラーのどちらかを出してから完了しなければならない。
型定義のコメントにも明記されている。
This Observable must eventually emit a value or an error before it completes … If the Observable completes without ever emitting anything (for example because of
catchError(() => EMPTY)), Angular throwsRESOURCE_COMPLETED_BEFORE_PRODUCING_VALUE(NG0991)
原因として catchError(() => EMPTY) が名指しされている点に注目したい。エラーを握りつぶす書き方が、そのまま NG0991 を引き起こす。
readonly users = rxResource({
params: () => ({ keyword: this.keyword() }),
stream: ({ params }) =>
this.api.search(params.keyword).pipe(
catchError(() => EMPTY),
@Reviewer`EMPTY` を返すと値もエラーも出さずに完了するため、`NG0991` になります。エラーはそのまま流し、`error()` 側で扱ってください。 ),
});Resource はエラーを状態として持てるので、握りつぶす必要がない。エラーをそのまま通し、error() で読む。
なお、params が undefined を返す場合は読み込み自体が実行されず、idle の状態になる。検索キーワードが未入力のときに通信を走らせたくない、といった制御はここで書ける。
他の Resource を前提にする params
params に渡す関数は引数を1つ受け取る。型は ResourceParamsContext で、持っているメンバーは chain だけである。
readonly user = httpResource<User>(() => `/api/users/${this.userId()}`);
readonly orders = rxResource({
params: (ctx) => ({ customerId: ctx.chain(this.user).customerId }),
stream: ({ params }) => this.api.orders(params.customerId),
});ctx.chain(this.user) は user の状態を見て、resolved か local のときだけ値を返す。それ以外の状態では例外を投げ、その例外の種類によって orders 側の状態が決まる。
user の状態 |
chain が投げるもの |
orders の状態 |
|---|---|---|
idle |
ResourceParamsStatus.IDLE |
idle(通信しない) |
loading / reloading |
ResourceParamsStatus.LOADING |
loading |
error |
ResourceDependencyError |
error |
ResourceDependencyError は dependency に失敗した Resource 自身を持つため、orders.error() から失敗の出どころを辿れる。
chain を使わず this.user.value() を直接読む書き方でも、エラー状態なら例外が投げられ、params から外へ出た例外はそのまま orders のエラーになる。差が出るのは読み込み中である。user が loading のあいだ value() は既定値を返すため、params は既定値から組み立てた要求を返し、orders は意味のない引数で通信を始める。chain はこの状態を待ち状態として伝える。
@Reviewer: `params` の中で先行する Resource を `value()` で読んでいます。読み込み中は既定値が返るため、未取得の値で通信が走ります。`params: (ctx) => ...` の `ctx.chain()` 経由にしてください。なお、params の中で別の Resource を作ることはできない。NG0992 が投げられ、これは状態にならずそのまま例外として外へ出る。
set() と update() が作る local 状態
resource() が返す ResourceRef は set() と update() を持つ。これを呼ぶと、取得した値ではなくローカルに書いた値を保持する状態、つまり local になる。
状態は6つあり、value() の扱いがそれぞれ違う。
| 状態 | value() |
入る条件 |
|---|---|---|
idle |
既定値 | params が undefined を返した |
loading |
既定値 | 参照しているパラメータが変わって読み込みが始まった |
reloading |
直前の値 | reload() で読み直している |
resolved |
取得した値 | 読み込みが完了した |
local |
書き込んだ値 | set() または update() を呼んだ |
error |
例外を投げる | 読み込みが失敗した |
local は hasValue() が真になる状態であり、isLoading() も偽である。画面の分岐としては resolved と区別せずに書ける。区別が要るのは、その値をサーバーが知らない点を扱う場合である。楽観的更新として先に set() で書いたなら、保存が成功したあとに reload() で取り直すのか、そのまま local の値を正解として扱うのかを決めておかなければならない。reload() を呼べば reloading を経て resolved に戻り、ローカルに書いた値は捨てられる。
@Reviewer: `set()` で先に画面を更新していますが、保存要求が失敗した場合に `local` の値が残ります。失敗時に `reload()` で取り直すか、直前の値へ戻す処理を入れてください。SSRで取得した値を引き継ぐ id
resource() と rxResource() の id は、サーバーで取得した値をクライアントへ渡すためのキーである。指定すると、サーバー側では解決した値を TransferState に書き、クライアント側では最初の評価でそのキーを読む。値があれば通信せずに resolved から始まる。
httpResource() にこのオプションは無い。HttpClient 経由の要求は provideClientHydration(withHttpTransferCacheOptions(...)) 側のキャッシュが担うため、id の役割が重複する。
レビューで確認する点は2つある。キーがサーバーとクライアントで同一であること、そして同じキーを複数の Resource で使っていないことである。キーが食い違えばクライアントで通信が走り直し、同じキーを使い回せば後から評価された Resource が別の Resource の値を resolved として受け取る。どちらも型では止まらない。
Resource と書き込み処理の境界
Resource 系は読み取り向けのAPIである。httpResource() は既定で GET を行い、参照しているパラメータが変わると再実行される。設定次第で他のメソッドや body も指定できるが、参照の変更で勝手に再実行されうる仕組みの上に更新処理を載せることになる。
保存や削除には HttpClient を直接使い、必要な競合制御を自分で組む。読み取りの状態管理と、書き込みの実行制御は別の問題として扱う。
中断の扱いも確認しておきたい。rxResource() はパラメータが変わると以前の Observable の購読を解除し、httpResource() は以前のHTTP要求を中断する。resource() の abortSignal は、loader の側でそれを実際の通信へ渡してはじめて働く。渡し忘れると、中断したつもりの通信が続く。
レビュー観点チェックリスト
value()を読む前にhasValue()かerror()で分岐しているかdefaultValueをエラー時の代替値だと解釈していないか- 失敗したことが画面に出るか。値が無い状態と区別されているか
- パラメータ変更と
reload()のどちらで再読み込みするか、画面の見え方と合っているか streamに渡す Observable が、値かエラーを必ず出してから完了するかcatchError(() => EMPTY)でエラーを握りつぶしていないか- 先行する Resource を参照する
paramsがctx.chain()を通しているか set()で書いた値(local)を、保存の失敗時にどう戻すか決まっているかidがサーバーとクライアントで一致し、他の Resource と重複していないか- 更新処理を Resource に載せていないか
おわりに
Resource 系は状態を4つの Signal で見せてくれるため、テンプレートから素直に書ける。素直に書けることと、全部を無条件で読んでよいことは別である。
value() だけが例外を投げうる、という一点を覚えておけば、分岐を書き忘れることはなくなる。分岐を書く過程で、失敗を利用者にどう伝えるかも決まる。