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 はエラー時の代替値を設定する機能ではない。パラメータの変更で読み込みが始まったときに戻る値であって、エラー状態には関与しない。

Comment
@Reviewer: `error` 状態で `value()` を読むと例外が投げられます。`defaultValue` はエラー時の代替値ではないので、`hasValue()` で分岐してから読んでください。

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() で読み直した場合は以前の値が保持される。

同じ「読み込み中」でも、前の内容が画面に残る場合と消える場合がある。一覧を再取得するたびに画面がちらつく、という指摘の原因がここにあることが多い。

Comment
@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 throws RESOURCE_COMPLETED_BEFORE_PRODUCING_VALUE (NG0991)

原因として catchError(() => EMPTY) が名指しされている点に注目したい。エラーを握りつぶす書き方が、そのまま NG0991 を引き起こす。

NG0991 になる実装
readonly users = rxResource({
  params: () => ({ keyword: this.keyword() }),
  stream: ({ params }) =>
    this.api.search(params.keyword).pipe(
      catchError(() => EMPTY),
@Reviewer
`EMPTY` を返すと値もエラーも出さずに完了するため、`NG0991` になります。エラーはそのまま流し、`error()` 側で扱ってください。
), });

Resource はエラーを状態として持てるので、握りつぶす必要がない。エラーをそのまま通し、error() で読む。

なお、paramsundefined を返す場合は読み込み自体が実行されず、idle の状態になる。検索キーワードが未入力のときに通信を走らせたくない、といった制御はここで書ける。

Resource と書き込み処理の境界

Resource 系は読み取り向けのAPIである。httpResource() は既定で GET を行い、参照しているパラメータが変わると再実行される。設定次第で他のメソッドや body も指定できるが、参照の変更で勝手に再実行されうる仕組みの上に更新処理を載せることになる。

保存や削除には HttpClient を直接使い、必要な競合制御を自分で組む。読み取りの状態管理と、書き込みの実行制御は別の問題として扱う。

中断の扱いも確認しておきたい。rxResource() はパラメータが変わると以前の Observable の購読を解除し、httpResource() は以前のHTTP要求を中断する。resource()abortSignal は、loader の側でそれを実際の通信へ渡してはじめて働く。渡し忘れると、中断したつもりの通信が続く。

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

Resourceを見たときの確認項目
  • value() を読む前に hasValue()error() で分岐しているか
  • defaultValue をエラー時の代替値だと解釈していないか
  • 失敗したことが画面に出るか。値が無い状態と区別されているか
  • パラメータ変更と reload() のどちらで再読み込みするか、画面の見え方と合っているか
  • stream に渡す Observable が、値かエラーを必ず出してから完了するか
  • catchError(() => EMPTY) でエラーを握りつぶしていないか
  • 更新処理を Resource に載せていないか

おわりに

Resource 系は状態を4つの Signal で見せてくれるため、テンプレートから素直に書ける。素直に書けることと、全部を無条件で読んでよいことは別である。

value() だけが例外を投げうる、という一点を覚えておけば、分岐を書き忘れることはなくなる。分岐を書く過程で、失敗を利用者にどう伝えるかも決まる。