- Angular
- TypeScript
- 技術 💻
Angular httpResource使い方|v22安定版の実例とテスト
Angular の httpResource とは、signal に連動して HTTP の GET を自動で実行し、結果・読み込み中・エラーを signal で返す API です。 汎用版の resource(Promise を返す関数で読み込む)と、RxJS 版の rxResource(Observable を返す関数で読み込む)があり、3つとも Angular v22 で安定版(型定義で @publicApi 22.0)になりました。 v19〜v21 の実験的 API のころとは、request → params、loader → stream(rxResource)など名前が変わっている点に注意が必要です。
この記事では Angular 22.2.0 で、ローカルのモック API を相手に 一覧取得・ID 切り替え(前のリクエストのキャンセル)・404 エラー表示・chain() による依存リクエスト・set/update と reload・zod での検証を実際に動かし、Playwright で状態の移り変わりを記録しました。HttpClient+subscribe からの書き換え前後、provideHttpClientTesting を使った Vitest のテストと実行結果、テストでつまずいた点も載せています。
httpResource / resource の早見表
| やりたいこと | 書き方 | 補足 |
|---|---|---|
| GET で JSON を取得 | httpResource<Task[]>(() => '/api/tasks') |
@angular/common/http から import |
| 初期値を空配列にする | httpResource(() => url, { defaultValue: [] }) |
読み込み中・idle の間も value() が [] |
| signal の変化で取り直す | httpResource(() => '/api/tasks/' + id()) |
前のリクエストは自動でキャンセル |
| 条件がそろうまで送らない | 関数から undefined を返す |
状態は idle |
| クエリ・ヘッダー付き | httpResource(() => ({ url, params: { q: kw() }, headers: { ... } })) |
オブジェクト形式 |
| レスポンスを検証・変換 | { parse: TaskListSchema.parse } |
zod などの関数をそのまま渡せる |
| 文字列・Blob で受け取る | httpResource.text() / .blob() / .arrayBuffer() |
既定は JSON |
| 前のリソースの結果を使う | httpResource(({ chain }) => '/api/users/' + chain(task)?.ownerId) |
上流の loading / error が伝わる |
| 取り直す | res.reload() |
状態は reloading、値は保持 |
| 手元で書き換える | res.set(v) / res.update(fn) |
状態は local |
| fetch など Promise で読む | resource({ params, loader }) |
abortSignal を fetch に渡すとキャンセル可 |
| Observable で読む | rxResource({ params, stream }) |
@angular/core/rxjs-interop から import |
| 入力が止まってから検索 | debounced(() => kw(), 300) + chain() |
v22 で experimental |
| テストでレスポンスを返す | provideHttpClientTesting() + HttpTestingController |
expectOne().flush() |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular httpResource使い方|v22安定版の実例とテスト」
resource・rxResource・httpResource の使い分け
| API | import 元 | 読み込み関数 | 向いている場面 |
|---|---|---|---|
httpResource |
@angular/common/http |
URL かリクエストオブジェクトを返す関数 | HTTP の GET で読むだけ。インターセプターやテスト用バックエンドもそのまま使える |
rxResource |
@angular/core/rxjs-interop |
stream: ({ params }) => Observable |
Observable を返す既存サービスを使う、retry などの演算子を足したい |
resource |
@angular/core |
loader: ({ params, abortSignal }) => Promise |
fetch・IndexedDB・Web Worker など HTTP 以外の非同期処理 |
3つとも戻り値は同じ ResourceRef 系で、状態の読み方(value・status・error・isLoading・hasValue)は共通です。 POST・PUT などの更新処理は、公式ドキュメントでも httpResource ではなく HttpClient を直接使うよう案内されています。
共通のプロパティ・メソッド
| 名前 | 型 | 内容 |
|---|---|---|
value() |
signal | 最新の値。error 状態で読むと例外を投げる |
hasValue() |
メソッド | 値があるか。true なら型から undefined が外れる |
status() |
signal | 6つの状態(下表) |
error() |
signal | error 状態のときのエラー |
isLoading() |
signal | loading か reloading なら true |
snapshot() |
signal | 状態と値(またはエラー)をまとめたオブジェクト |
reload() |
メソッド | 同じ条件で取り直す |
set() / update() |
メソッド | 手元で値を書き換える |
destroy() |
メソッド | 破棄し、実行中のリクエストをキャンセル |
headers() / statusCode() / progress() |
signal | httpResource のみ。レスポンスヘッダー、HTTP ステータス、進捗 |
status の6つの値
| status | いつ | value() |
|---|---|---|
idle |
params(URL)が undefined で、何もしていない |
defaultValue(なければ undefined) |
loading |
params が変わって読み込み中 | defaultValue(前の値は消える) |
reloading |
reload() で読み込み中 |
前の値を保持 |
resolved |
読み込み完了 | 結果 |
local |
set() / update() で書き換えた |
書き換えた値 |
error |
読み込み失敗 | 読むと例外。error() を見る |
検証環境とモック API
| 項目 | バージョン |
|---|---|
| Angular(core / common / cli) | 22.2.0(zoneless 既定) |
| zod | 4.6.5 |
| Vitest | 5.0.2(ng test) |
| Node.js | 24.21.0 |
| Playwright / Chromium | 1.56.1 / 141 |
プロジェクトは npx -y -p @angular/cli@22 ng new res-app --defaults --skip-git --style=css --routing=false --ssr=false --ai-config=none で作成し、npm i zod を追加しました。モック API はビルド成果物の配信も兼ねた Node の小さなサーバーで、?delay=ミリ秒 で応答を遅らせ、途中で接続が切られたら ABORTED と記録します。
// server.mjs(node server.mjs dist/res-app/browser 7123)
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const [dist, port] = [process.argv[2], Number(process.argv[3] ?? 7120)];
const tasks = [
{ id: 1, title: '備品リストを更新する', done: false, ownerId: 10 },
{ id: 2, title: '会議室を予約する', done: true, ownerId: 11 },
{ id: 3, title: '週報を書く', done: false, ownerId: 10 },
];
const users = { 10: { id: 10, name: '佐藤' }, 11: { id: 11, name: '鈴木' } };
const types = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
if (url.pathname.startsWith('/api/')) {
console.log(new Date().toISOString().slice(11, 23), 'REQ', url.pathname + url.search);
let closed = false;
req.on('close', () => { if (!res.writableEnded) { closed = true; console.log(new Date().toISOString().slice(11, 23), 'ABORTED', url.pathname); } });
await sleep(Number(url.searchParams.get('delay') ?? 300));
if (closed) return;
let m;
if (url.pathname === '/api/tasks') return json(200, tasks);
if ((m = url.pathname.match(/^\/api\/tasks\/(\d+)$/))) {
const t = tasks.find((x) => x.id === Number(m[1]));
return t ? json(200, t) : json(404, { message: `task ${m[1]} not found` });
}
if ((m = url.pathname.match(/^\/api\/users\/(\d+)$/))) return users[m[1]] ? json(200, users[m[1]]) : json(404, {});
if (url.pathname === '/api/broken') return json(200, [{ id: 'x', title: 42 }]); // 型が違うデータ
return json(404, { message: 'not found' });
}
let file = path.join(dist, url.pathname === '/' ? 'index.html' : url.pathname);
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(dist, 'index.html');
res.writeHead(200, { 'content-type': types[path.extname(file)] ?? 'application/octet-stream' });
fs.createReadStream(file).pipe(res);
}).listen(port, () => console.log('listening', port));
v21 以降は HttpClient が既定で注入できるため、今回の app.config.ts には provideHttpClient() を書いていません(それでも動きました)。 インターセプター(withInterceptors)や withFetch() などを設定する場合は、これまでどおり provideHttpClient(...) を書きます。
書き換え前:HttpClient+subscribe
ID を切り替えて詳細を取る処理を、signal と RxJS で書いた例です(ビルドが通ることを確認済み)。
import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { Subject, switchMap } from 'rxjs';
import { Task } from './task.model';
@Component({
selector: 'app-task-detail-before',
template: `
@if (loading()) { 読み込み中... }
@else if (error()) { エラー: {{ error() }} }
@else if (task(); as t) { {{ t.title }} }
`,
})
export class TaskDetailBefore implements OnInit {
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
private id$ = new Subject<number>();
task = signal<Task | undefined>(undefined);
loading = signal(false);
error = signal<string | undefined>(undefined);
ngOnInit() {
this.id$
.pipe(
switchMap((id) => { // 前のリクエストをキャンセルするため switchMap
this.loading.set(true);
this.error.set(undefined);
return this.http.get<Task>(`/api/tasks/${id}`);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (t) => { this.task.set(t); this.loading.set(false); },
error: (e) => { this.error.set(e.message); this.loading.set(false); }, // エラー後は購読が終わる
});
}
select(id: number) {
this.id$.next(id);
}
}
読み込み中・エラー・値の3つの signal、Subject、switchMap、購読解除を自分で管理する必要があります。 また、この書き方では一度エラーになると購読が終わり、次に ID を選んでも取得されません(catchError を switchMap の内側に入れる工夫が必要です)。
書き換え後:httpResource(コピペで動く完成形)
// src/app/task.model.ts
import { z } from 'zod';
export const TaskSchema = z.object({
id: z.number(),
title: z.string(),
done: z.boolean(),
ownerId: z.number(),
});
export type Task = z.infer<typeof TaskSchema>;
export const TaskListSchema = z.array(TaskSchema);
export interface User { id: number; name: string }
// src/app/app.ts
import { Component, computed, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Task, TaskListSchema, User } from './task.model';
@Component({
selector: 'app-root',
template: `
<h2>タスク一覧 <small data-testid="list-status">[{{ tasks.status() }}]</small></h2>
<button (click)="tasks.reload()">再読み込み</button>
<ul>
@for (t of tasks.value(); track t.id) {
<li>
<button (click)="selectedId.set(t.id)">#{{ t.id }}</button>
{{ t.title }} {{ t.done ? '(完了)' : '' }}
<button (click)="toggleLocal(t.id)">完了切替(ローカル)</button>
</li>
}
</ul>
<button (click)="selectedId.set(99)">#99(存在しない)</button>
<h2>詳細 <small data-testid="detail-status">[{{ task.status() }}]</small></h2>
<div data-testid="detail">
@if (task.hasValue()) {
{{ task.value().title }}(担当: {{ owner.hasValue() ? owner.value().name : '…' }})
} @else if (task.error(); as err) {
エラー: {{ task.statusCode() }} {{ err.message }}
} @else if (task.isLoading()) {
読み込み中...
}
</div>
<p data-testid="owner-status">担当者リソース: [{{ owner.status() }}]</p>
<h2>スキーマ検証</h2>
<p data-testid="broken">{{ broken.error()?.message ?? broken.status() }}</p>
`,
})
export class App {
// 1) 一覧: defaultValue で「空配列」から始める
readonly tasks = httpResource(() => '/api/tasks', {
parse: TaskListSchema.parse,
defaultValue: [],
});
// 2) 詳細: selectedId が変わるたびに自動で再取得(前のリクエストはキャンセル)
readonly selectedId = signal<number | undefined>(undefined);
readonly task = httpResource<Task>(() => {
const id = this.selectedId();
return id === undefined ? undefined : `/api/tasks/${id}?delay=800`;
});
// 3) 依存リクエスト: task が resolved になってから担当者を取得
readonly owner = httpResource<User>(({ chain }) => {
const task = chain(this.task); // task が resolved でなければ、ここで状態が伝わる
return task ? `/api/users/${task.ownerId}` : undefined;
});
// 4) parse でレスポンスを検証(壊れたデータを返す API)
readonly broken = httpResource(() => '/api/broken', { parse: TaskListSchema.parse });
readonly doneCount = computed(() => this.tasks.value().filter((t) => t.done).length);
toggleLocal(id: number) {
this.tasks.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
}
@if (task.hasValue()) の中では task.value() の型から undefined が外れるので、task.value().title とそのまま書けます。hasValue() は error 状態で value() を読んで例外になるのを防ぐ役目もあります。
実行結果:状態の移り変わり(Playwright)
本番ビルドをモックサーバーで配信し、Playwright で画面の表示とネットワークを記録しました。
182ms 一覧[loading] 詳細[idle] 「」 担当者リソース: [idle]
804ms 一覧[resolved] 詳細[idle] 「」 担当者リソース: [idle]
804ms --- #1 → #2 → #3 を 100ms 間隔でクリック
973ms requestfailed /api/tasks/1 net::ERR_ABORTED
1106ms requestfailed /api/tasks/2 net::ERR_ABORTED
1201ms 一覧[resolved] 詳細[loading] 「読み込み中...」 担当者リソース: [loading]
2111ms 一覧[resolved] 詳細[resolved] 「週報を書く(担当: …)」 担当者リソース: [loading]
2525ms 一覧[resolved] 詳細[resolved] 「週報を書く(担当: 佐藤)」 担当者リソース: [resolved]
2525ms --- #99
3559ms 一覧[resolved] 詳細[error] 「エラー: 404 Http failure response for http://localhost:7123/api/tasks/99?delay=800: 404 Not Found」 担当者リソース: [error]
3559ms --- 完了切替(ローカル) を1件目で
3653ms 一覧[local] ... / 1件目: #1 備品リストを更新する (完了) 完了切替(ローカル)
3653ms --- 再読み込み
3730ms 一覧[reloading] ...
4245ms 一覧[resolved] ... / 1件目: #1 備品リストを更新する 完了切替(ローカル)
同じ時間帯のモックサーバーのログです。
03:41:26.151 REQ /api/tasks/1?delay=800
03:41:26.275 REQ /api/tasks/2?delay=800
03:41:26.279 ABORTED /api/tasks/1
03:41:26.409 ABORTED /api/tasks/2
03:41:26.409 REQ /api/tasks/3?delay=800
03:41:27.218 REQ /api/users/10
03:41:27.859 REQ /api/tasks/99?delay=800
03:41:28.990 REQ /api/tasks
結果から読み取れることを表にまとめます。
| 確認したこと | 結果 |
|---|---|
| ID を素早く切り替えたとき | #1・#2 のリクエストはブラウザ側でもサーバー側でも中断(ABORTED)され、最後の #3 だけが完了 |
chain() の依存リクエスト |
task が loading の間は owner も loading。task が resolved になった直後に /api/users/10 を送信 |
| 404 のとき | task は error、statusCode() は 404。chain している owner にも error が伝わった |
update() で書き換え |
状態が local になり、画面に「(完了)」が付く(サーバーには送らない) |
reload() |
reloading の間も一覧は表示されたまま。完了後はサーバーの値(未完了)に戻る |
switchMap・catchError・loading フラグを書かずに、キャンセルとエラー処理、依存関係の状態伝播まで揃うのが httpResource の強みです。書き換え前の subscribe 版とは違い、404 のあとで別の ID を選べば、そのまま再取得されます。
parse でレスポンスを検証する(zod)
/api/broken は [{ id: 'x', title: 42 }] という型の合わないデータを返します。parse: TaskListSchema.parse を渡した broken リソースの error().message は次のとおりでした(先頭のみ)。
[ { "expected": "number", "code": "invalid_type", "path": [ 0, "id" ], "message": "Invalid input: expected number, received string" }, { "expected": "string", "code": "invalid_type", "path": [ 0, "title" ], "message": "Invalid input: expected string, received number" }, ...
parse が例外を投げると、リソースは error 状態になります。 型の不一致を画面の奥で undefined エラーとして踏むのではなく、取得した時点で気づけるのが利点です。parse の戻り値の型がそのまま value() の型になるので、ジェネリクス(httpResource<Task[]>)を書く必要もありません。
一方でバンドルサイズは増えます。今回の構成で ng build の Initial total を比べると、zod なし 153.06kB、zod 使用 607.41kB、zod/mini 使用 580.76kB でした(zod/mini は parse: (v) => z.parse(TaskListSchema, v) と書きます)。検証を入れる画面を限定する、手書きの型チェック関数を parse に渡す、といった選択肢も検討してください。
resource と rxResource の書き方
HTTP 以外や既存の Observable を使う場合の例です(上の画面に組み込んで動作確認しました)。
// src/app/extras.ts
import { Component, inject, resource, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs';
import { Task } from './task.model';
@Component({
selector: 'app-extras',
template: `
<input data-testid="kw" (input)="keyword.set($any($event.target).value)" placeholder="キーワード" />
<p data-testid="fetch">resource: [{{ byFetch.status() }}] {{ byFetch.value()?.join(', ') }}</p>
<p data-testid="rx">rxResource: [{{ byRx.status() }}] {{ byRx.value() }}件</p>
`,
})
export class Extras {
keyword = signal('');
// resource + fetch: abortSignal を渡すと、キーワード変更時に前の fetch が中断される
byFetch = resource({
params: () => ({ q: this.keyword() }),
loader: async ({ params, abortSignal }) => {
const res = await fetch(`/api/tasks?delay=500&q=${encodeURIComponent(params.q)}`, { signal: abortSignal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const list: Task[] = await res.json();
return list.filter((t) => t.title.includes(params.q)).map((t) => t.title);
},
});
// rxResource: Observable を返す stream を使う
private http = inject(HttpClient);
byRx = rxResource({
params: () => this.keyword(),
stream: ({ params }) =>
this.http.get<Task[]>('/api/tasks').pipe(map((list) => list.filter((t) => t.title.includes(params)).length)),
defaultValue: 0,
});
}
「備品」と1文字ずつ(100ms 間隔)入力したときの記録です。
927ms resource: [resolved] 備品リストを更新する, 会議室を予約する, 週報を書く | rxResource: [resolved] 3件
927ms --- 「備品」と1文字ずつ入力(100ms間隔)
1145ms requestfailed ?delay=500&q=%E5%82%99 net::ERR_ABORTED
1145ms requestfailed net::ERR_ABORTED
1146ms resource: [loading] | rxResource: [loading] 0件
1851ms resource: [resolved] 備品リストを更新する | rxResource: [resolved] 1件
1文字目(「備」)のリクエストは、resource(fetch)も rxResource(HttpClient)も2文字目の入力で中断されました。 resource では abortSignal を fetch に渡さないと中断されない点に注意してください。また、loading の間 rxResource の値は前の「3件」ではなく defaultValue の 0 に戻っています。前の値を表示し続けたい場合は、linkedSignal などで直前の値を別に持つ必要があります。
rxResource の stream が値を1つも出さずに完了すると(例:catchError(() => EMPTY))、型定義のコメントによると NG0991 のエラーになります。エラーは値に変えるか、そのまま流して error() で受け取るのがよいでしょう。
入力が止まってから検索する:debounced(experimental)
v22 では debounced()(@angular/core、experimental)が追加されました。戻り値がリソースなので、chain() と組み合わせられます。
import { Component, debounced, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
@Component({
selector: 'app-task-search',
template: `<input (input)="keyword.set($any($event.target).value)" />
<p>[{{ result.status() }}] {{ result.value() }}</p>`,
})
export class TaskSearch {
keyword = signal('');
q = debounced(() => this.keyword(), 300); // 300ms 入力が止まったら確定
result = httpResource(({ chain }) => `/api/tasks?q=${chain(this.q)}`); // 確定するまで loading
}
Vitest で「備 → 備品」と 100ms 間隔で入力し、その後 300ms 進めるテストを実行したところ、送信されたリクエストは /api/tasks?q=備品 の1本だけで、待っている間の result.status() は loading でした。experimental のため、今後 API が変わる可能性があります。
Vitest でテストする(provideHttpClientTesting)
// src/app/app.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { App } from './app';
describe('App(httpResource)', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [App],
providers: [provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
function setup() {
const fixture = TestBed.createComponent(App);
TestBed.tick(); // httpResource がリクエストを出す
http.expectOne('/api/broken').flush([]);
return { fixture, app: fixture.componentInstance };
}
it('一覧を取得して表示する', async () => {
const { fixture, app } = setup();
expect(app.tasks.status()).toBe('loading');
expect(app.tasks.value()).toEqual([]); // defaultValue
http.expectOne('/api/tasks').flush([
{ id: 1, title: '備品リストを更新する', done: false, ownerId: 10 },
]);
await fixture.whenStable();
expect(app.tasks.status()).toBe('resolved');
expect(fixture.nativeElement.textContent).toContain('備品リストを更新する');
});
it('ID を切り替えると前のリクエストはキャンセルされる', async () => {
const { fixture, app } = setup();
http.expectOne('/api/tasks').flush([]);
app.selectedId.set(1);
TestBed.tick();
const first = http.expectOne('/api/tasks/1?delay=800');
app.selectedId.set(2);
TestBed.tick();
expect(first.cancelled).toBe(true);
http.expectOne('/api/tasks/2?delay=800')
.flush({ id: 2, title: '会議室を予約する', done: true, ownerId: 11 });
await Promise.resolve(); // レスポンスがリソースに反映されるのを待つ(マイクロタスク1回)
TestBed.tick();
http.expectOne('/api/users/11').flush({ id: 11, name: '鈴木' });
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('会議室を予約する(担当: 鈴木)');
});
it('404 のとき error 状態になり、value() は例外を投げる', async () => {
const { fixture, app } = setup();
http.expectOne('/api/tasks').flush([]);
app.selectedId.set(99);
TestBed.tick();
http.expectOne('/api/tasks/99?delay=800')
.flush({ message: 'task 99 not found' }, { status: 404, statusText: 'Not Found' });
await fixture.whenStable();
expect(app.task.status()).toBe('error');
expect(app.task.statusCode()).toBe(404);
expect(app.owner.status()).toBe('error'); // chain() でエラーが伝わる
expect(() => app.task.value()).toThrow();
expect(app.task.hasValue()).toBe(false);
});
});
✓ |res-app| src/app/app.spec.ts > App(httpResource) > 一覧を取得して表示する 48ms
✓ |res-app| src/app/app.spec.ts > App(httpResource) > ID を切り替えると前のリクエストはキャンセルされる 11ms
✓ |res-app| src/app/app.spec.ts > App(httpResource) > 404 のとき error 状態になり、value() は例外を投げる 10ms
Test Files 1 passed (1)
Tests 3 passed (3)
テストでつまずいた点
| 症状 | 原因 | 対策 |
|---|---|---|
expectOne が「found none」で失敗 |
リソースのリクエストは変更検知(effect)の中で送られる | TestBed.tick() を呼んでから expectOne |
| 依存リクエスト(chain)が「found none」 | flush() の結果がリソースに反映されるのは次のマイクロタスク |
await Promise.resolve() → TestBed.tick() の順にしたら通過(実測) |
await fixture.whenStable() がタイムアウト |
保留中の HTTP があるとアプリが安定しない | 先に flush() してから whenStable |
first.cancelled が false |
ID を変えただけでは再計算されていない | set() のあとに TestBed.tick() |
表の2行目の「await Promise.resolve()」は、今回の Angular 22.2.0 で通った方法です。環境によって必要な待ち方が変わる可能性があるため、落ちたときは TestBed.tick() と await fixture.whenStable() の位置を見直してください。
よくあるつまずき
| 症状・疑問 | 原因 | 対処 |
|---|---|---|
value() を読んだら例外 |
error 状態で value() を読んだ |
テンプレートは @if (res.hasValue()) で囲む |
URL に undefined が入る |
条件がそろう前に URL を組み立てた | 関数から undefined を返して idle にする |
| 前の値が一瞬消える | params が変わると loading で値が defaultValue に戻る |
仕様どおり。値を残すのは reload() のとき |
| POST をリソースで送りたい | リソースは読み込み用 | HttpClient.post() を直接使い、成功後に reload() |
request / loader が型エラー |
v19 時代の名前 | v22 は params、rxResource は stream |
| fetch が中断されない | abortSignal を渡していない |
fetch(url, { signal: abortSignal }) |
| インターセプターが効かない | provideHttpClient を書いていない |
provideHttpClient(withInterceptors([...])) を追加 |
よくある質問
Q. httpResource はいつ安定版になりましたか?
A. v22 です。@angular/common/http の型定義で httpResource・HttpResourceRef などが @publicApi 22.0、resource・rxResource も @publicApi 22.0 になっています。debounced と resourceFromSnapshots は v22 時点でも experimental です。
Q. httpResource はキャッシュしてくれますか?
A. 公式ガイドには、画面をまたいで結果を共有するキャッシュ機能の説明はありません。用意されているのは、SSR でサーバーの取得結果をブラウザで再利用する仕組み(HttpClient の transfer cache、resource の id オプション)です。複数の画面で同じ結果を使い回したい場合は、リソースをサービス(providedIn: 'root')に置いて共有するのが基本です。
Q. httpResource と toSignal(http.get()) はどう違いますか?
A. toSignal は一度だけ購読し、読み込み中やエラーの状態を持ちません。httpResource は signal の変化で取り直し、status・error・isLoading・reload を最初から備えています。ID などに応じて取り直す画面では httpResource が短く書けます。
Q. resource の loader で HttpClient を使ってもよいですか?
A. 使えますが、Observable を Promise に変える手間があるので、HttpClient なら httpResource か rxResource のほうが自然です。resource は fetch や IndexedDB など Promise を返す API に向いています。
まとめ
httpResource・resource・rxResourceは v22 で安定版。GET は httpResource、Observable は rxResource、Promise は resource- signal を読む関数を渡すだけで自動再取得、前のリクエストは中断される(実測で ABORTED を確認)
- 状態は idle / loading / reloading / resolved / local / error の6つ。
reload()は値を保持、params 変更は値がリセット chain()で依存リクエストの loading・error が下流にも伝わるparseで zod などの検証を挟める。ただしバンドルサイズの増加も確認するvalue()は error 状態で例外になるので、hasValue()で囲む- テストは
provideHttpClientTesting+TestBed.tick()+flush()、whenStable は flush の後に
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular httpResource使い方|v22安定版の実例とテスト」
株式会社RJCでは、新しく安定版になった API を手元で動かして挙動を確かめ、わかったことをメンバー同士で共有しながら開発に生かしています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。