AngularのhttpResourceとHttpClientの手組みをどちらで書くか

httpResource() を使えば、取得中かどうかと取得した値と失敗したときのエラーが、3つの signal として揃う。HttpClient を注入して subscribe し、loading と data と error のプロパティを自分で持ち回る実装と比べれば、書く量は目に見えて減る。

減った分は Angular が引き受けている。引き受けた範囲の中には、こちらが指定したかった判断も含まれる。どこまでが自動で、どこからが書けないのかを @angular/common 22.2.1 の実装から確かめると、手組みを選ぶべき差分が分かれる。

自動で入るものの範囲

httpResource() が返す実体は _ResourceImpl を継承したクラスで、親に渡す loader の中で HttpClient.request を購読している。中断の扱いはその loader の冒頭にある。

22.2.1 の HttpResourceImpl の loader(冒頭)
super(request, ({ params: request, abortSignal }) => {
  let sub;
  let aborted = false;
  const onAbort = () => {
    aborted = true;
    sub?.unsubscribe();
  };
  abortSignal.addEventListener('abort', onAbort, { once: true });
  // ...
  sub = this.client.request(request).subscribe({ /* ... */ });
  if (aborted) {
    sub.unsubscribe();
  }
  return promise;
}, defaultValue, equal, debugName, injector, undefined, getInitialStream);

abortSignal は Resource 側が持つもので、要求を決める関数が参照している signal が変わったときと、destroy() が呼ばれたときに発火する。発火すれば購読が解除される。手組みで switchMap を書いて得るのと同じ打ち切りが、書かずに入る。

状態の遷移も入る。status() は 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local' の6値を取り、2回目以降の取得は loading ではなく reloading になる。reloading の間、value() は前回の値を返し続ける。手組みで loading を真偽値1つで持つと、再取得のたびに値が消えて画面が空になるか、空にしないために値と読み込み中の旗を別々に管理することになる。

購読を1本に絞る点と、再取得中に前の値を残す点は、手で書くと取り違えやすい。この2つが要るだけなら httpResource() を選ぶ理由は足りている。

loader を書けないことの帰結

resource() を直接使う場合、loader は ResourceLoaderParams を受け取る。

22.2.1 の ResourceLoaderParams
interface ResourceLoaderParams<R> {
    params: NoInfer<Exclude<R, undefined>>;
    abortSignal: AbortSignal;
    previous: {
        status: ResourceStatus;
    };
}

httpResource() はこの loader を自分で埋める。利用者に渡るオプションは HttpResourceOptions で、parse、defaultValue、injector、equal、debugName の5つしかない。abortSignal も previous も手元に来ない。

ここから2つの判断が書けなくなる。1つ目は、前回の状態による分岐である。previous.status が 'resolved' かどうかで取得先を変える、再取得のときだけ条件を足す、といった実装は httpResource() では表せない。2つ目は、独自の再試行である。abortSignal を受け取れないため、指数的に待ち時間を伸ばして呼び直す関数を loader の位置に差し込めない。reload() は呼べるが、これは1回の再取得であって、待ち時間や上限を決める場所が無い。

再試行をインターセプターで足す道は残っている。ただしインターセプター内の retry は下流の本体を再実行せず、付与済みのヘッダーのまま再購読する。この性質はAngularの関数型インターセプターの順序と二重実行をレビューするで確かめた。要求ごとに違う再試行の方針が要るなら、インターセプターではなく loader の側に置くことになり、そこで resource() か rxResource() を選ぶ。

Comment
@Reviewer: この一覧は失敗したときに3回まで待ち時間を伸ばして呼び直す仕様だったと思いますが、`httpResource()` では loader を書けないため、再試行を入れる場所がありません。`reload()` は1回分です。待ち時間と上限を持つなら `rxResource()` に変えて `retry({ count: 3, delay: ... })` を loader の中に置いてください。要求の組み立てはそのまま移せます。

error() に入る値の型

失敗の経路を見ると、HTTP のエラーはそのまま流される。

22.2.1 の HttpResourceImpl の error ハンドラー
error: error => {
  if (error instanceof HttpErrorResponse) {
    this._headers.set(error.headers);
    this._statusCode.set(error.status);
  }
  send({ error });
  abortSignal.removeEventListener('abort', onAbort);
},

send({ error }) は受けた値を包み直さない。Resource の error signal は Signal<Error | undefined> と宣言されているので、型の上では Error が入るように見える。実際に入るのは HttpErrorResponse である。

22.2.1 の HttpErrorResponse
declare class HttpErrorResponse extends HttpResponseBase implements Error {
    readonly name = "HttpErrorResponse";
    readonly message: string;
    readonly error: any | null;
    readonly ok = false;
    // ...
}

extends HttpResponseBase implements Error である。Error を継承しておらず、name と message を持つことで構造的に Error を満たしているだけである。したがって error() instanceof Error は偽になる。

包み直す関数の判定も同じ構造を見ている。

22.2.1 の isErrorLike
function isErrorLike(error) {
  return error instanceof Error || typeof error === 'object' && error !== null && typeof error.name === 'string' && typeof error.message === 'string';
}

name と message が文字列なら素通りする。HttpErrorResponse はこの条件を満たすので、parse が投げた場合に通る encapsulateResourceError を経由しても包まれない。instanceof Error で分岐している既存のエラー表示は、httpResource() の失敗を拾えない。状態コードで分けるなら statusCode() を読み、エラーの種別で分けるなら instanceof HttpErrorResponse を書く。

Comment
@Reviewer: `error() instanceof Error` で分けていますが、`HttpErrorResponse` は `Error` を継承せず `implements Error` で満たしているだけなので、この条件はHTTPの失敗では偽になります。通信が失敗したときに else 側の「不明なエラー」が出る状態です。状態コードで分けたいのでしたら `statusCode()` を読んでください。

parse の失敗を受ける2つの経路

parse は応答の本体を検証して型を絞る位置にある。zod のようなスキーマを通す使い方が想定されていて、投げれば Resource がエラー状態になる。

22.2.1 の応答を受けた側
case HttpEventType.Response:
  this._headers.set(event.headers);
  this._statusCode.set(event.status);
  try {
    send({ value: parse ? parse(event.body) : event.body });
  } catch (error) {
    send({ error: _encapsulateResourceError(error) });
  }
  break;

同じ parse が、サーバー側で取得した値を引き継ぐ経路でも呼ばれる。そちらでは投げても状態にならない。

22.2.1 の getInitialStream
const getInitialStream = req => {
  if (cacheOptions && transferState && req) {
    const cachedResponse = retrieveStateFromCache(req, cacheOptions, transferState, originMap);
    if (cachedResponse) {
      try {
        const body = cachedResponse.body;
        const parsed = options?.parse ? options.parse(body) : body;
        return signal({ value: parsed });
      } catch (e) {
        if (typeof ngDevMode === 'undefined' || ngDevMode) {
          console.warn(`Angular detected an error while parsing the cached response for the httpResource at \`${req.url}\`. ` + `The resource will fall back to its default value and try again asynchronously.`, e);
        }
      }
    }
  }
  return undefined;
};

catch の中は開発モードの警告だけで、エラー状態にはならない。undefined を返すので既定値から始まり、通常の要求が改めて走る。つまり同じ検証の失敗が、ハイドレーション時は「警告が出て再取得」、通常の応答では「エラー状態」になる。本番ビルドでは警告も出ない。

スキーマの不一致を error() で検知して記録する作りにしている場合、SSR で引き継いだ応答の不一致は記録に残らない。残すなら parse の中で記録を呼ぶ。この差は httpResource() に固有で、resource() の loader に検証を書いた場合は転送キャッシュを通る経路が無いため起きない。Resource のエラー状態をテンプレートでどう扱うかはAngularのResourceでhasValue()を省いた実装をレビューで止めるにまとめた。

要求の組み立てで落ちるもの

要求を決める関数が返せるのは文字列か HttpResourceRequest で、これが HttpRequest に変換される。

22.2.1 の normalizeRequest(変換部)
return new HttpRequest(unwrappedRequest.method ?? 'GET', unwrappedRequest.url, unwrappedRequest.body ?? null, {
  headers,
  params,
  reportDownloadProgress: unwrappedRequest.reportProgress,
  withCredentials: unwrappedRequest.withCredentials,
  // ...
  responseType,
  context: unwrappedRequest.context,
  transferCache: unwrappedRequest.transferCache,
  // ...
  timeout: unwrappedRequest.timeout
});

reportProgress の行き先は reportDownloadProgress だけである。22 で HttpRequest.reportProgress が非推奨になり reportUploadProgress と reportDownloadProgress に分かれたが、HttpResourceRequest 側に上りを指定する項目は無い。ファイルの送信の進捗を出す画面は httpResource() では書けない。HttpClient.request を直接呼び、reportUploadProgress: true を付けて HttpEventType.UploadProgress を拾う。Fetch 既定になった後の通信コードの前提はAngular 22でHttpClientがFetch既定になった後の通信コードのレビュー観点にまとめた。

responseType も要求の側からは指定できない。httpResource() が json、httpResource.text() が text、.blob() が blob、.arrayBuffer() が arraybuffer を埋めるので、呼ぶ関数で決まる。observe: 'response' に相当する指定も無く、本体以外は headers() と statusCode() から読む。この2つは要求が変わるとリセットされる linkedSignal で、set() を呼んだときにも undefined に戻る。

22.2.1 の set()
set(value) {
  super.set(value);
  this._headers.set(undefined);
  this._progress.set(undefined);
  this._statusCode.set(undefined);
}

楽観的更新のために set() を呼んだあと statusCode() を読んでいる箇所があれば、そこは常に undefined を見る。

書き込みをどちらに置くか

要求を決める関数は、参照している signal が変わるたびに呼ばれる。method: 'POST' と書くことはできるが、それは依存が変わるたびに送信が走る実装になる。

送信が繰り返される書き方
readonly submitted = httpResource<Receipt>(() => ({
  url: '/api/orders',
  method: 'POST',
  body: { itemId: this.itemId(), quantity: this.quantity() },
}));

quantity() を1つ動かすたびに注文が作られる。reload() を呼べばもう1つ作られる。Resource 系は読み取りのための API で、送信は HttpClient を注入して利用者の操作から呼ぶ。読み取り側の Resource は、送信が成功したあとに reload() で更新する。

Comment
@Reviewer: `httpResource()` の要求関数は依存している signal が変わるたびに呼ばれるため、この POST は数量を変えるたびに送信されます。送信は `HttpClient` を注入してボタンの処理から呼び、一覧側の `httpResource()` は成功後に `reload()` してください。

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

httpResource と手組みの選択を確かめる項目
  • 再試行の待ち時間や上限を決めている仕様が、httpResource() で表せない形になっていないか
  • 前回の状態(初回か再取得か)で分岐する実装を httpResource() に押し込んでいないか
  • error() を instanceof Error で判定していないか(HttpErrorResponse は偽になる)
  • 状態コードで分岐する箇所が statusCode() を読んでいるか
  • parse にスキーマ検証を置いた場合、SSR から引き継いだ応答の不一致が記録に残る作りか
  • 上りの進捗が要る要求を httpResource() で書いていないか(下りだけが届く)
  • set() のあとに headers() や statusCode() を読んでいないか(undefined に戻る)
  • 非 GET の要求を httpResource() に置いていないか(依存の変化と reload() で再送される)
  • 手組みに残した箇所で、要求が切り替わったときに前の購読を解除しているか
  • 手組みの読み込み中の旗が、再取得のあいだ前の値を消していないか

おわりに

httpResource() が引き受けるのは、要求の切り替えに伴う購読の打ち切りと、6値の状態遷移と、再取得中に前の値を残すことである。この3つを手で書くと取り違えやすいので、読み取りの既定として選べる。

渡されないのは loader である。abortSignal と previous.status が手元に来ないため、再試行の方針と前回の状態による分岐は書けない。加えて、error() に入るのは Error を継承しない HttpErrorResponse であり、parse の失敗は転送キャッシュを通る経路だけ扱いが変わり、上りの進捗は届かない。

選択の基準はこの範囲で引ける。GET で、再試行の方針がアプリ全体で共通で、進捗が下りだけなら httpResource() に寄せる。要求ごとに再試行を変える、前回の状態で分岐する、上りの進捗を出す、のいずれかがあれば rxResource() か HttpClient の手組みに移す。送信はどちらの場合も HttpClient 側に置く。

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