Angular の HttpClient とは、サーバーの API と HTTP でやり取りするための標準サービスで、inject(HttpClient) で受け取り、http.get('/api/tasks') のようにメソッドを呼んで使います。 戻り値は Observable で、subscribe・firstValueFrom・toSignal のいずれかで結果を受け取ります。応答が 4xx・5xx だったときや通信できなかったときは、HttpErrorResponse がエラーとして届きます。

この記事では、GET・POST・PUT・PATCH・DELETE を型付きで書いたサービスの完成コード、params・headers・observe・responseType などのオプション早見表、status 0 / 4xx / 5xx それぞれの HttpErrorResponse の実際の中身、「No provider for HttpClient」の再現と直し方、subscribe・firstValueFrom・toSignal の使い分けをまとめました。掲載している出力は、ローカルに立てた小さな API サーバー(Node.js)に対して、ブラウザ(Chromium)から実際にリクエストを送って得たものです。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular 22.2.1(@angular/common・@angular/cli)/ RxJS 7.8.2 / TypeScript 6.0.3 / Playwright 1.56.0 + Chromium 141 / Vitest 5.0.3。プロバイダーに関するエラーの再現だけ、Angular 20.3.33 のプロジェクトも使っています。

HttpClient の早見表

やりたいこと 書き方 戻り値の型
一覧を取得する http.get<Task[]>('/api/tasks') Observable<Task[]>
条件を付けて取得する http.get<Task[]>('/api/tasks', { params: { done: false } }) Observable<Task[]>
1件取得する http.get<Task>('/api/tasks/1') Observable<Task>
新規作成する http.post<Task>('/api/tasks', { title: '...' }) Observable<Task>
全体を置き換える http.put<Task>('/api/tasks/1', task) Observable<Task>
一部を更新する http.patch<Task>('/api/tasks/1', { done: true }) Observable<Task>
削除する http.delete<void>('/api/tasks/1') Observable<void>
ヘッダーを付ける { headers: { 'X-Request-Id': 'r-1' } } —
ステータスやヘッダーも受け取る { observe: 'response' } Observable<HttpResponse<Task>>
文字列で受け取る { responseType: 'text' } Observable<string>
ファイルとして受け取る { responseType: 'blob' } Observable<Blob>
時間切れを設定する { timeout: 300 }(ミリ秒) —
エラーを受け取る subscribe({ error: (e: HttpErrorResponse) => ... }) または try / catch —
エラーを別の値に置き換える .pipe(catchError(() => of([]))) —
自動でやり直す .pipe(retry({ count: 2, delay: 100 })) —

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular HttpClientの使い方|GET・POSTとエラー処理」

準備:provideHttpClient の設定

HttpClient の設定は、app.config.ts の providers に provideHttpClient() を書いて行います。

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './http/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Angular のバージョンによって、必要な書き方が少し変わります。公式ドキュメント(HTTP のセットアップ)と Angular 22.2.1 の型定義で確認した内容は次のとおりです。

項目 Angular 22.2.1 での扱い
HttpClient の注入 公式ドキュメントに「Angular v21 以降は既定で注入できる」とあり、provideHttpClient() を書かなくても動く
通信の方式 既定で fetch API を使う
withFetch() 型定義で非推奨(withFetch is not required anymore)。書いてあっても動く
withXhr() XMLHttpRequest を使う方式に切り替える。アップロードの進捗を取りたいときに使う
withInterceptors([...]) 関数形式のインターセプターを登録する

Angular 22 では、インターセプターなどの追加設定がなければ provideHttpClient() 自体を省けます。 実際に、provideHttpClient を一切書かない状態でこの記事の CRUD をすべて実行でき、Vitest でも provideHttpClient(withFetch()) と書いた設定が引き続き通ることを確かめました。一方、Angular 20 までのプロジェクトでは provideHttpClient() が必要です(後半の「No provider for HttpClient」の節で再現します)。どのバージョンでも動く形にしておきたいなら、provideHttpClient() を書いておくのが分かりやすい選択です。

型付きの完成コード:GET・POST・PUT・PATCH・DELETE

HTTP の呼び出しは、コンポーネントに直接書かずにサービスにまとめるのが基本です。次の TaskApi は、タスクの一覧・取得・作成・置き換え・一部更新・削除をひととおり備えています。

import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';

export interface Task {
  id: number;
  title: string;
  done: boolean;
}
export type NewTask = Pick<Task, 'title'>;

@Injectable({ providedIn: 'root' })
export class TaskApi {
  private readonly http = inject(HttpClient);
  private readonly base = '/api/tasks';

  /** GET /api/tasks?done=false */
  list(filter?: { done?: boolean }): Observable<Task[]> {
    let params = new HttpParams();
    if (filter?.done !== undefined) params = params.set('done', filter.done);
    return this.http.get<Task[]>(this.base, { params });
  }

  /** GET /api/tasks/1 */
  get(id: number): Observable<Task> {
    return this.http.get<Task>(`${this.base}/${id}`);
  }

  /** POST /api/tasks(本文は JSON に変換されて送られる) */
  create(task: NewTask): Observable<Task> {
    return this.http.post<Task>(this.base, task);
  }

  /** POST でステータスやヘッダーも受け取る */
  createWithResponse(task: NewTask): Observable<HttpResponse<Task>> {
    return this.http.post<Task>(this.base, task, { observe: 'response' });
  }

  /** PUT /api/tasks/1(全体を置き換える) */
  replace(task: Task): Observable<Task> {
    return this.http.put<Task>(`${this.base}/${task.id}`, task);
  }

  /** PATCH /api/tasks/1(一部だけ変える) */
  update(id: number, changes: Partial<Omit<Task, 'id'>>): Observable<Task> {
    return this.http.patch<Task>(`${this.base}/${id}`, changes);
  }

  /** DELETE /api/tasks/1(204 No Content なので本文なし) */
  remove(id: number): Observable<void> {
    return this.http.delete<void>(`${this.base}/${id}`);
  }
}

http.get<Task[]>(...) の山かっこの中が、応答の本文の型です。この型は「こういう形で返ってくるはず」という宣言で、実行時に中身を検査するものではありません。 サーバーの応答と合っていることは、API の仕様やテストで確かめます。

実行結果

ブラウザから順に呼んだときの戻り値です。

list: [{"id":1,"title":"備品を発注する","done":false},{"id":2,"title":"議事録を書く","done":true}]
list(done=false): [{"id":1,"title":"備品を発注する","done":false}]
get(1): {"id":1,"title":"備品を発注する","done":false}
create: {"id":3,"title":"在庫を数える","done":false}
createWithResponse: status=201 ok=true Location=/api/tasks/4 body={"id":4,"title":"棚を整理する","done":false}
replace: {"id":3,"title":"在庫を数え直す","done":false}
update: {"id":3,"title":"在庫を数え直す","done":true}
remove: null
delete observe=response: status=204 body=null

サーバー側に届いたリクエストは次のとおりでした。

GET /api/tasks
GET /api/tasks?done=false
GET /api/tasks/1
POST /api/tasks
POST /api/tasks
PUT /api/tasks/3
PATCH /api/tasks/3
DELETE /api/tasks/4
DELETE /api/tasks/3
メソッド 第2引数 ポイント
get(url, options) オプション 本文は送らない。条件は params で渡す
post(url, body, options) 本文 オブジェクトを渡すと JSON に変換して送られる
put(url, body, options) 本文 対象の全体を、送った内容で置き換える
patch(url, body, options) 本文 渡した項目だけ変える(update の結果で title はそのまま)
delete(url, options) オプション 204 No Content のときは null が流れる。型は void にしておくと扱いやすい

post・put・patch は第2引数が本文、第3引数がオプションです。get と delete はオプションが第2引数なので、書き換えるときに位置を間違えやすい点です。

オプション早見表:params・headers・observe・responseType

メソッドの最後の引数に渡すオプションです。Angular 22.2.1 の型定義にある項目を一覧にしました。

オプション 指定できる値 働き
params オブジェクト、または HttpParams クエリ文字列を付ける。値はエンコードされる
headers オブジェクト、または HttpHeaders リクエストヘッダーを付ける
observe 'body'(既定) / 'response' / 'events' 何を受け取るか。本文だけ、応答全体、途中経過を含むイベント
responseType 'json'(既定) / 'text' / 'blob' / 'arraybuffer' 本文の読み取り方
timeout ミリ秒 指定時間で応答がなければ中断してエラーにする
withCredentials true / false 別オリジンへのリクエストに Cookie などを含める
reportProgress true / false observe: 'events' と組み合わせて進捗イベントを受け取る
context HttpContext インターセプターに渡す目印を付ける
transferCache オブジェクト、または真偽値 サーバー側レンダリングで取得した応答の引き継ぎを調整する
keepalive true / false ページを離れたあともリクエストを続ける
cache 'no-cache' など ブラウザの HTTP キャッシュとの関わり方
priority 'high' / 'low' / 'auto' リクエストの優先度の目安
mode 'cors' / 'same-origin' / 'no-cors' 別オリジンの扱い
redirect 'follow' / 'error' / 'manual' リダイレクト応答の扱い
credentials 'include' など 認証情報を送る範囲
referrer / referrerPolicy URL / ポリシー名 リファラーの内容と送る範囲
integrity ハッシュ文字列 応答が改ざんされていないかの検証

keepalive から下は fetch API 由来のオプションで、意味は公式ドキュメント(リクエストの作成)の説明に沿っています。

params と headers の実際の送られ方

受け取ったクエリ文字列とヘッダーをそのまま返す API に送って確かめました。

http.get('/api/echo', { params: { q: '備品 A&B', page: 2, tag: ['x', 'y'], done: false } });
http.get('/api/echo', { headers: new HttpHeaders({ Authorization: 'Bearer abc' }).set('X-Request-Id', 'r-1') });
http.get('/api/echo', { headers: { 'X-Request-Id': 'r-2' } });
params object: {"query":"?q=%E5%82%99%E5%93%81%20A%26B&page=2&tag=x&tag=y&done=false","authorization":null,"xRequestId":null,"accept":"application/json, text/plain, */*"}
headers: {"query":"","authorization":"Bearer abc","xRequestId":"r-1","accept":"application/json, text/plain, */*"}
headers object: {"query":"","authorization":null,"xRequestId":"r-2","accept":"application/json, text/plain, */*"}
  • 日本語・空白・& は自動でエンコードされる(備品 A&B → %E5%82%99%E5%93%81%20A%26B)。URL を文字列の連結で組み立てるより、params に任せるほうが確実です。
  • 配列は同じキーの繰り返しになる(tag=x&tag=y)。数値や真偽値もそのまま渡せます。
  • HttpParams と HttpHeaders は変更できないオブジェクトです。set() は新しいオブジェクトを返すので、params = params.set('done', true) のように受け直します(TaskApi.list の書き方)。
  • 何も指定しなくても、Accept: application/json, text/plain, */* が付きます。

observe と responseType の実際の戻り値

responseType text: string "v1.2.3"
responseType blob: size=33 type=text/csv; charset=utf-8
responseType arraybuffer: byteLength=33
observe events: ["Sent","ResponseHeader","DownloadProgress","Response"]
指定 受け取れるもの 使いどころ
指定なし JSON を変換した本文 ほとんどの API
observe: 'response' HttpResponse(status・headers・body) 201 の Location ヘッダー、ページ送り用のヘッダーなどを読みたい
observe: 'events' + reportProgress: true 送信・ヘッダー受信・ダウンロード進捗・完了のイベント 進捗バーを出したい
responseType: 'text' 文字列 JSON 以外のテキストを返す API
responseType: 'blob' Blob CSV・PDF・画像などのダウンロード

responseType に 'text' などを指定するときは、get<string>(...) のような型引数は付けません。戻り値の型は指定に合わせて決まります。

エラー処理:HttpErrorResponse の中身を status ごとに見る

HttpClient のエラーは、HttpErrorResponse というオブジェクトで届きます。 まず、状況ごとに各プロパティが実際にどうなったかを一覧にします。

状況 status statusText error の中身
接続できない(サーバーが動いていないポート) 0 Unknown Error TypeError: Failed to fetch
CORS で止められた(別オリジンで許可ヘッダーなし) 0 Unknown Error TypeError: Failed to fetch
timeout: 300 で時間切れ 0 Unknown Error TimeoutError: signal timed out
400(入力エラー) 400 Bad Request {"message":"title は必須です","field":"title"}
401(ログインが必要) 401 Unauthorized {"message":"ログインが必要です"}
404(見つからない) 404 Not Found {"message":"タスク 999 は見つかりません"}
500(本文がテキスト) 500 Internal Server Error "Internal Server Error"(文字列)
200 だが JSON として読めない 200 OK SyntaxError: Expected double-quoted property name in JSON ...
200 のテキストを JSON として受けた 200 OK SyntaxError: Unexpected token 'v', "v1.2.3" is not valid JSON

message プロパティは、次のような文になります。

404: Http failure response for http://127.0.0.1:8760/api/tasks/999: 404 Not Found
500: Http failure response for http://127.0.0.1:8760/api/error500: 500 Internal Server Error
接続できない: Http failure response for http://127.0.0.1:8769/api/tasks: 0 undefined
JSON として読めない: Http failure during parsing for http://127.0.0.1:8760/api/broken

どの場合も name は HttpErrorResponse、ok は false でした。

3つのグループに分けて考える

グループ 見分け方 何が起きているか 画面での案内の例
status 0 e.status === 0 応答を受け取れていない(接続・CORS・時間切れ) 通信環境の確認と再試行をお願いする
4xx e.status >= 400 && e.status < 500 リクエストの内容に理由がある e.error の内容をもとに、直す箇所を伝える
5xx e.status >= 500 サーバー側で処理できなかった 時間をおいての再試行をお願いする

status 0 のときは、接続できないのか CORS なのかを HttpErrorResponse からは区別できません(どちらも TypeError: Failed to fetch)。原因を調べるときは、ブラウザの開発者ツールのコンソールを見ます。今回の実行では、接続できない場合は net::ERR_CONNECTION_REFUSED、CORS の場合は has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present と、別々のメッセージが出ていました。

4xx・5xx のとき、e.error にはサーバーが返した本文が入ります。 JSON ならオブジェクト、テキストなら文字列です。サーバーが {"message": "..."} の形で返す約束になっていれば、e.error?.message をそのまま画面に出せます。

ステータスが 200 でも、本文を JSON として読めなければエラーになります。 テキストを返す API を responseType の指定なしで呼んだ場合がこれにあたり、responseType: 'text' を付けると解決します。

エラーを文章に変える関数

status ごとの分岐は、1つの関数にまとめておくと各画面で使い回せます。

import { HttpErrorResponse } from '@angular/common/http';

/** HttpErrorResponse を、画面に出す文章に変える */
export function toMessage(e: unknown): string {
  if (!(e instanceof HttpErrorResponse)) return '予期しないエラーが発生しました。';
  if (e.status === 0) return 'サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。';
  if (e.status === 200) return 'サーバーからの応答を読み取れませんでした。';
  if (e.status === 400) return e.error?.message ?? '入力内容を確認してください。';
  if (e.status === 401) return 'ログインが必要です。';
  if (e.status === 404) return e.error?.message ?? '対象が見つかりませんでした。';
  if (e.status >= 500) return 'サーバー側で問題が起きています。時間をおいてお試しください。';
  return `エラーが発生しました(${e.status})。`;
}

subscribe の error で受け取る

api.get(999).subscribe({
  next: (t) => console.log('next: ' + JSON.stringify(t)),
  error: (e: HttpErrorResponse) => console.log(`error: ${e.status} ${JSON.stringify(e.error)}`),
  complete: () => console.log('complete'),
});

存在する ID(1)と存在しない ID(999)で呼んだ結果です。

next: {"id":1,"title":"備品を発注する","done":false}
complete
error: 404 {"message":"タスク 999 は見つかりません"}

成功すると next が1回、続けて complete が呼ばれます。エラーのときは error だけが呼ばれ、complete は呼ばれません。 「読み込み中」の表示を消す処理は、complete だけでなく error 側にも必要です(RxJS の finalize を使うと1か所にまとめられます)。

catchError と retry で流れの中で処理する

// 404 のときは null に置き換え、それ以外のエラーはそのまま流す
api.get(999).pipe(
  catchError((e: HttpErrorResponse) => (e.status === 404 ? of(null) : throwError(() => e))),
);

// エラーになったら 100 ミリ秒あけて最大2回やり直す
http.get('/api/flaky').pipe(retry({ count: 2, delay: 100 }));
catchError で null に置き換え: null
retry({count: 2, delay: 100}): {"attempt":3}

/api/flaky は「3回に1回だけ成功する」API です。サーバーのログには GET /api/flaky が3回並び、3回目の応答(attempt: 3)が届きました。

演算子 働き 向いている場面
catchError(() => of(代わりの値)) エラーを通常の値に置き換える 一覧が取れなければ空の配列で表示を続ける
catchError((e) => throwError(() => e)) 記録などをしてから、エラーのまま流す サービスでログを取り、画面側でメッセージを出す
retry({ count, delay }) 同じリクエストをやり直す 一時的な混雑や通信の揺らぎ
timeout オプション 指定時間で中断する 応答を待ち続けたくない処理

retry は GET のような「何度送っても結果が同じ」リクエストに使うのが基本です。POST をやり直すと、サーバー側の作りによっては同じデータが2件できることがあります。

catchError(() => EMPTY) で「何も流さずに終わる」形にした場合、firstValueFrom と組み合わせると EmptyError: no elements in sequence になりました。firstValueFrom を使う箇所では、of(null) のように何か1つ値を流す形にしておきます。

subscribe・firstValueFrom・toSignal の使い分け

HttpClient が返す Observable は、購読されるまで何も送りません。 また、購読するたびにリクエストが送られます。同じ Observable を2回 subscribe したところ、サーバーには2回届きました。

GET /api/echo?cold=1
GET /api/echo?cold=1

「メソッドを呼んだのにリクエストが飛ばない」ときは、購読を書き忘れていないかを確認しましょう。受け取り方は3つあります。

方法 書き方 向いている場面 気を付ける点
toSignal tasks = toSignal(api.list(), { initialValue: [] }) 画面を開いたときに読み込んで表示するデータ 注入コンテキスト(フィールド初期化やコンストラクター)で呼ぶ。エラーは catchError で値に変えておく
subscribe api.list().subscribe({ next, error }) 成功時とエラー時で別々の処理をしたい コンポーネントで使うときは takeUntilDestroyed() を挟む
firstValueFrom const t = await firstValueFrom(api.create(...)) ボタン操作など、1回送って結果を待つ処理 エラーは try / catch で受け取る

3つを使ったコンポーネントの完成コード

import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { firstValueFrom, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { toMessage } from './http-error-message';
import { Task, TaskApi } from './task-api';

@Component({
  selector: 'app-task-list',
  template: `
    <h2>タスク</h2>
    <p id="count">完了済み(初回読み込み時): {{ doneTasks().length }}件</p>

    <input #title id="new-title" placeholder="タスク名" />
    <button id="add" (click)="add(title.value); title.value = ''" [disabled]="saving()">追加</button>

    @if (message(); as m) {
      <p id="message" role="alert">{{ m }}</p>
    }

    <ul id="tasks">
      @for (t of tasks(); track t.id) {
        <li>
          {{ t.title }}
          <button class="remove" (click)="remove(t.id)">削除</button>
        </li>
      }
    </ul>
  `,
})
export class TaskList {
  private readonly api = inject(TaskApi);
  private readonly destroyRef = inject(DestroyRef);

  protected readonly tasks = signal<Task[]>([]);
  protected readonly saving = signal(false);
  protected readonly message = signal('');

  // ① toSignal:読み込んで表示するだけのデータ。購読と解除を任せられる
  protected readonly doneTasks = toSignal(
    this.api.list({ done: true }).pipe(catchError(() => of([] as Task[]))),
    { initialValue: [] as Task[] },
  );

  constructor() {
    // ② subscribe:next / error を分けて書く。コンポーネント破棄時に自動で解除
    this.api
      .list()
      .pipe(takeUntilDestroyed())
      .subscribe({
        next: (list) => this.tasks.set(list),
        error: (e) => this.message.set(toMessage(e)),
      });
  }

  // ③ firstValueFrom:ボタン操作など「1回送って結果を待つ」処理を async/await で書く
  protected async add(title: string): Promise<void> {
    this.saving.set(true);
    this.message.set('');
    try {
      const created = await firstValueFrom(this.api.create({ title }));
      this.tasks.update((list) => [...list, created]);
    } catch (e) {
      this.message.set(toMessage(e));
    } finally {
      this.saving.set(false);
    }
  }

  protected remove(id: number): void {
    this.api
      .remove(id)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next: () => this.tasks.update((list) => list.filter((t) => t.id !== id)),
        error: (e) => this.message.set(toMessage(e)),
      });
  }
}

ブラウザで、表示→追加→タスク名を空のまま追加→削除→通信を遮断して追加、の順に操作した結果です。

表示直後: ["備品を発注する","議事録を書く"] / 完了済み(初回読み込み時): 1件 / message=null
追加後: ["備品を発注する","議事録を書く","在庫を数える"] / 完了済み(初回読み込み時): 1件 / message=null
空で追加: ["備品を発注する","議事録を書く","在庫を数える"] / 完了済み(初回読み込み時): 1件 / message="title は必須です"
削除後: ["議事録を書く","在庫を数える"] / 完了済み(初回読み込み時): 1件 / message="title は必須です"
通信を遮断して追加: ["議事録を書く","在庫を数える"] / 完了済み(初回読み込み時): 1件 / message="サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"

400 のときはサーバーが返した message が、通信できなかったとき(status 0)は toMessage の文章が表示されています。どちらの場合も一覧はそのまま残り、利用者はすぐに操作を続けられます。

公式ドキュメントでは、購読を解除すると進行中のリクエストが取り消されること、async パイプや toSignal を使うと購読の後始末を任せられることが説明されています。画面を離れたあとに届いた応答で処理が走らないよう、コンポーネント内の subscribe には takeUntilDestroyed() を付けておくと安心です。

インターセプターで共通の処理をまとめる

すべてのリクエストに同じヘッダーを付けたい、エラーを1か所で記録したい、というときはインターセプターを使います。関数として書き、provideHttpClient(withInterceptors([...])) で登録します(登録は冒頭の app.config.ts を参照)。

import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';

/** すべてのリクエストにトークンを付け、エラーを1か所で記録する */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const withToken = req.clone({ setHeaders: { Authorization: 'Bearer demo-token' } });
  return next(withToken).pipe(
    catchError((e: HttpErrorResponse) => {
      console.warn(`[http] ${req.method} ${req.url} -> ${e.status}`);
      return throwError(() => e);
    }),
  );
};

先ほどの画面操作の裏で、ブラウザから送られたリクエスト(上の6行)と、コンソールに出た警告(下の2行)は次のとおりでした。

GET /api/tasks?done=true Authorization=Bearer demo-token
GET /api/tasks Authorization=Bearer demo-token
POST /api/tasks Authorization=Bearer demo-token
POST /api/tasks Authorization=Bearer demo-token
DELETE /api/tasks/1 Authorization=Bearer demo-token
POST /api/tasks Authorization=Bearer demo-token
[console.warn] [http] POST /api/tasks -> 400
[console.warn] [http] POST /api/tasks -> 0

リクエストは変更できないオブジェクトなので、req.clone({ setHeaders: {...} }) で複製してから next に渡します。 サービス側のコードを変えずに、全リクエストへ同じ処理を足せるのが利点です。

No provider for HttpClient の再現と直し方

NullInjectorError: No provider for HttpClient! は、HttpClient を注入しようとしたが、プロバイダーが登録されていないときのエラーです。公式ドキュメントでは、エラーコード NG0201(No Provider Found)として「プロバイダーを宣言していないサービスを注入しようとすると発生する」と説明されています。

Angular 20 で再現する

Angular 20.3.33 のプロジェクトで、app.config.ts に provideHttpClient を書かずに、コンポーネントで inject(HttpClient) だけを行いました。

import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<h1>tasks</h1>`,
})
export class App {
  private readonly http = inject(HttpClient);
}

ng build(本番用ビルド)は通り、ブラウザで開くとコンソールに次のエラーが出て、画面には何も表示されませんでした。

[console.error] ERROR m: NG0201
    at Et.get (http://127.0.0.1:8762/main-VCTJNSPX.js:4:6413)
    ...

本番用ビルドではメッセージが短縮され、エラーコードの NG0201 だけが表示されます。コンソールに NG0201 と出て画面が真っ白なら、プロバイダーの登録もれを最初に確認しましょう。

直し方:app.config.ts に provideHttpClient を足す

import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(withFetch())
  ]
};

この1行を足してビルドし直すと、エラーは消えて tasks の見出しが表示されました。

バージョンごとの整理

状況 結果 対応
Angular 20.3.33 で provideHttpClient なし NG0201 で画面が表示されない providers に provideHttpClient()(fetch を使うなら withFetch() も)を足す
Angular 22.2.1 で provideHttpClient なし そのまま動く 対応不要。インターセプターなどを使うときに書く
Angular 22.2.1 のテストで HttpTestingController を注入 NG0201: No provider found for HttpTestingController. テストの providers に provideHttpClientTesting() を足す

3行目は Angular 22.2.1 のテストで実際に出たメッセージで、全文は次のとおりです。

NG0201: No provider found for `HttpTestingController`. Find more at https://v22.angular.dev/errors/NG0201

「No provider」と出たら、名前に入っているサービスを提供する provide〜() 関数を providers に足す、と覚えておけば、HttpClient 以外でも同じ手順で解決できます。

HttpClient を使うサービスのテスト

provideHttpClientTesting() を使うと、実際の通信をせずに「どんなリクエストが送られたか」と「応答を受けたときの動き」を確かめられます。次のテストは Vitest(ng test)で通ったものです。

import { HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { authInterceptor } from './auth.interceptor';
import { TaskApi } from './task-api';

describe('TaskApi', () => {
  let api: TaskApi;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting()],
    });
    api = TestBed.inject(TaskApi);
    http = TestBed.inject(HttpTestingController);
  });
  afterEach(() => http.verify());

  it('list は done を params に付ける', async () => {
    const promise = firstValueFrom(api.list({ done: false }));
    const req = http.expectOne('/api/tasks?done=false');
    expect(req.request.method).toBe('GET');
    expect(req.request.headers.get('Authorization')).toBe('Bearer demo-token');
    req.flush([{ id: 1, title: '備品を発注する', done: false }]);
    expect(await promise).toHaveLength(1);
  });

  it('create は JSON を POST する', async () => {
    const promise = firstValueFrom(api.create({ title: '在庫を数える' }));
    const req = http.expectOne({ method: 'POST', url: '/api/tasks' });
    expect(req.request.body).toEqual({ title: '在庫を数える' });
    req.flush({ id: 3, title: '在庫を数える', done: false }, { status: 201, statusText: 'Created' });
    expect((await promise).id).toBe(3);
  });

  it('404 は HttpErrorResponse として届く', async () => {
    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
    const promise = firstValueFrom(api.get(999));
    http.expectOne('/api/tasks/999').flush({ message: '見つかりません' }, { status: 404, statusText: 'Not Found' });
    await expect(promise).rejects.toBeInstanceOf(HttpErrorResponse);
    await expect(promise).rejects.toMatchObject({ status: 404, error: { message: '見つかりません' } });
    expect(warn).toHaveBeenCalledWith('[http] GET /api/tasks/999 -> 404');
  });
});
 ✓  demo  src/app/http/task-api.spec.ts > TaskApi > list は done を params に付ける 23ms
 ✓  demo  src/app/http/task-api.spec.ts > TaskApi > create は JSON を POST する 12ms
 ✓  demo  src/app/http/task-api.spec.ts > TaskApi > 404 は HttpErrorResponse として届く 6ms

flush の第2引数に { status: 404, statusText: 'Not Found' } を渡すと、エラー応答も再現できます。画面のエラー表示をテストするときにも同じ方法が使えます。

つまずきやすい点のチェックリスト

起きること 確認するところ 直し方
メソッドを呼んでもリクエストが送られない 購読しているか subscribe・firstValueFrom・toSignal・async パイプのいずれかで受け取る
同じリクエストが2回送られる 同じ Observable を2か所で購読していないか 購読を1か所にまとめ、結果を signal に入れて共有する
200 なのにエラーになる 応答が JSON か テキストなら responseType: 'text' を付ける
status が 0 で原因がわからない 開発者ツールのコンソール 接続先の URL・ポート、サーバー側の CORS 設定を確認する
post に渡したオプションが効かない 引数の位置 第2引数は本文、オプションは第3引数
params.set() したのに反映されない 戻り値を受け直しているか params = params.set(...) と書く
エラーのあと「読み込み中」が消えない error 側の処理 finalize か try / finally で後始末する
NG0201 で画面が出ない providers provideHttpClient() を足す(Angular 20 まで)

よくある質問

Q. HttpClientModule を imports する書き方との違いは何ですか。 A. provideHttpClient() は、app.config.ts の providers に書く関数形式の設定で、standalone 構成の Angular で使う形です。withInterceptors() などの機能を引数で足していけます。Angular 22.2.1 では HttpClient が既定で注入できるため、追加の設定がなければ何も書かなくても使えます。

Q. fetch API を直接使うのと何が違いますか。 A. HttpClient は、JSON の変換、型の指定、4xx・5xx をエラーとして扱うこと、インターセプター、テスト用の HttpTestingController をまとめて提供します。Angular 22.2.1 の HttpClient は内部で fetch API を使っているので、keepalive や priority などの fetch 由来のオプションも渡せます。

Q. エラー処理はサービスとコンポーネントのどちらに書きますか。 A. 「全リクエスト共通の処理(記録、トークンの付与)」はインターセプター、「この API 固有の置き換え(404 なら null)」はサービス、「利用者へのメッセージ表示」はコンポーネント、と分けると見通しがよくなります。この記事の authInterceptor・TaskApi・TaskList はその分け方にしています。

Q. 画面に表示するためのデータ取得なら、ほかの選択肢はありますか。 A. Angular 22.2.1 の @angular/common/http には、取得したデータをシグナルとして扱う httpResource も入っています。読み込みは httpResource、作成・更新・削除は HttpClient、という組み合わせも選べます。

まとめ

  • HttpClient は inject(HttpClient) で受け取り、get・post・put・patch・delete に型引数を付けて呼ぶ。呼び出しはサービスにまとめる。
  • Angular 22 では HttpClient が既定で使え、通信は fetch API。provideHttpClient() はインターセプターなどの設定を足すときに書く。Angular 20 までは必須。
  • オプションは params・headers・observe・responseType が中心。post 系は第3引数に渡す。
  • エラーは HttpErrorResponse。status 0 は応答なし、4xx はリクエスト側、5xx はサーバー側。e.error にサーバーの本文が入る。
  • 200 でも JSON として読めなければエラー。テキストは responseType: 'text' で受け取る。
  • 表示用は toSignal、分岐したいときは subscribe、1回の操作は firstValueFrom。購読しなければリクエストは送られない。
  • NG0201(No provider)は provideHttpClient() の登録を確認。テストでは provideHttpClientTesting()。

参考

  • Angular 公式ドキュメント「Setting up HttpClient」 https://angular.dev/guide/http/setup
  • Angular 公式ドキュメント「Making HTTP requests」 https://angular.dev/guide/http/making-requests
  • Angular 公式ドキュメント「NG0201: No Provider Found」 https://angular.dev/errors/NG0201

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular HttpClientの使い方|GET・POSTとエラー処理」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?