👁

Next.jsからのAPI呼び出し

この章では、Next.jsからのAPI呼び出しについて概要を解説し、ハンズオン形式で実際に動作させながら学習します。これにより、Route Handlersによる簡易API・fetch API・TanStack Queryを使い分けたデータ取得の基本が身につきます。

1. 本章の概要

1.1 本章の目的

実際のWebアプリケーションはバックエンドからデータを取得して画面に反映するのが基本形で、フロントエンド側のデータ取得のロジックが体験と保守性を大きく左右します。Next.jsではServer側/Client側のどちらでデータを取るかで手法が変わるため、選択肢を整理して押さえておきます。

1.2 ハンズオンの流れ

Route Handlersでダミーの内部APIを1つ用意したうえで、Client Componentから fetch でタスクの取得・送信を行います。続いて同じ処理を TanStack Query に書き換えて、キャッシュ・再取得の扱いを動かします。シンプルな fetch と TanStack Query の書き方の違いを比較しながら進めます。

1.3 事前準備

前提となる講座

この章では、以下の知識を前提としています。自信がない場合は先に関連講座を実施してみましょう。

講座名 必要な知識
Webアプリケーションの基本 HTTPリクエスト・レスポンスやREST APIの基本構造

必要なツール

この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。

ツール名 関連箇所 理由
Visual Studio Code Visual Studio Codeのインストール Route Handlersやフロントエンドコードを記述するエディタとして使用する
Node.js Node.jsをインストールしよう Next.jsアプリケーションの実行環境として使用する

2. データ取得の概要

いきなりコードに入る前に、フロントエンドがバックエンドとどのようにデータをやり取りするか、Next.jsではその関係をどう構築できるかを整理しておきます。全体像を把握してから手順に入ることで、コードを写経するだけの状態から一歩踏み出せます。

2.1 バックエンドとフロントエンドのデータのやり取り

Webアプリケーションでは、ブラウザで動くフロントエンドが、サーバ側のバックエンド(API)にリクエストを送信してデータを取得したり、書き込んだりします。多くの場合、両者の橋渡しに使われるのが REST API と呼ばれる設計スタイルです。REST APIでは、リソース(例: /tasks)に対してHTTPメソッド(GET / POST / PUT / DELETE)で操作を表現します。

HTTPメソッド 用途
GET リソースを取得する タスク一覧を取得する
POST リソースを新規作成する タスクを1件追加する
PUT / PATCH リソースを更新する タスクを完了状態に変える
DELETE リソースを削除する タスクを1件削除する

Webアプリケーションでは、フロントエンドから呼び出すAPIサーバをPythonやGoなど別の言語で作成し、Next.jsとは別プロジェクトとして運用するケースが多く見られます。一方でNext.jsには Route Handlers という仕組みがあり、フロントエンドと同じプロジェクトの中にAPIを書くこともできます。本章はフロントエンドからのデータ取得の練習が目的のため、別途APIサーバを立てる必要のないRoute Handlersを利用します。別リポジトリのAPIサーバと連携する構成でも、フロントエンドからAPIを呼び出す流れはほぼ同じです。

2.2 Route Handlersとは

Route Handlers は、Next.jsのApp Router配下でHTTPエンドポイントを定義する仕組みです。app/api/xxx/route.ts のようにフォルダを切って route.ts を置き、その中にHTTPメソッドと同名の関数(GET / POST / PUT / DELETE)を書くと、それがそのままAPIのエンドポイントになります。

たとえば app/api/tasks/route.tsGET を書くと GET /api/tasks として呼び出せるようになり、POST を書くと POST /api/tasks として呼び出せるようになります。ファイル配置のフォルダ構造がそのままURLパスに対応する ファイルベースルーティング の考え方は、Next.jsのページと同じです。

また、Next.jsではServer Componentから直接データベースやAPIにアクセスして描画する構成もありますが、本章ではClient Componentから fetch でAPIを呼び出す構成を扱います。この書き方は、別リポジトリのAPIサーバを呼び出すときにもほぼそのまま使えます。

2.3 fetch APIとTanStack Query

APIとの通信には、まず標準の fetch API を使います。ブラウザに組み込まれているHTTPクライアントで、追加ライブラリなしで使える一方、ロード中/エラーの状態は自分で管理する必要があります。

続いて、React向けのサーバ状態管理ライブラリである TanStack Query(旧称 React Query)に書き換えます。専用のフックを利用することで、ロード中・エラー・キャッシュ・再取得のロジックをまとめて任せられるようになります。この章では両方を順に書いて、シンプルな fetch の書き方と TanStack Query の書き方を比較します。

3. セットアップ

本章のフロントエンドのハンズオンに入る前に、下準備として2つの作業を行います。1つはハンズオン用のNext.jsプロジェクトの作成、もう1つはフロントエンドから呼び出す先となる動作確認用の簡易APIの作成です。どちらもこの後のfetch API / TanStack Query のハンズオンで共通して使う土台になります。

3.1 サンプルプロジェクトの準備

コードを書き始める土台として、Next.jsのプロジェクトを1つ用意します。前章の Next.jsのスタイリング で作ったプロジェクトをそのまま使っても構いませんが、この章では説明のしやすさを優先して新規プロジェクトを作成する前提で進めます。

任意の場所に作業用のフォルダを作り、その中でNext.jsプロジェクトを作成します。ここでは frontend-fetch-demo という名前でプロジェクトを作ります。ターミナル(macOSはターミナル、WindowsはPowerShell)を開きます。

以下のコマンドで、npm がインストールされていることを確認します。npx は npm に同梱されているため、npm のバージョンが確認できれば npx も利用できます。

npm --version

以下のように npm のバージョンが表示されれば、インストールは確認できています。

11.x.x

バージョンが表示されない場合は、Node.jsをインストールしよう を先に実施してください。

インストールが確認できたら、プロジェクトを置きたいフォルダに cd で移動してから、以下のコマンドを実行します。

npx create-next-app@latest frontend-fetch-demo

対話式の質問が表示されます。以下のように回答します。

質問 回答 設定の基準
Would you like to use the recommended Next.js defaults? Yes, use recommended defaults TypeScript / ESLint / Tailwind CSS / App Router / Turbopack など、本章のハンズオンに必要な構成が一括で揃う

インストールが終わったら、以下のコマンドで、作成したプロジェクトフォルダに移動します。

cd frontend-fetch-demo

続けて、このフォルダをVisual Studio Codeで開きます。「ファイル」→「フォルダーを開く」から frontend-fetch-demo フォルダを選択します。以降の操作はVisual Studio Codeのターミナルから行います。

まず、初期状態の画面を確認しておきます。ターミナルで以下を実行します。

npm run dev

ブラウザで http://localhost:3000 を開くと、以下のように Next.js のウェルカムページが表示されます。

画面中央に Next.js のロゴと初期テンプレートのリンクが表示されていれば、開発サーバは正常に起動しています。この開発サーバは以降のハンズオンでもずっと起動したままにします。

3.2 簡易APIの作成

以降のフロントエンドのハンズオンで呼び出す先として、Next.js の Route Handlers で動作確認用の簡易 API を用意します。本章のテーマはフロントエンド側のデータ取得であり、バックエンドの設計自体には踏み込まないため、同じNext.jsプロジェクト内に最小限の API を作るだけにとどめます。実装するのは、タスクの一覧を返す GET /api/tasks と、タスクを追加する POST /api/tasks の2つです。データはメモリ上の配列で持つだけの簡易実装にとどめ、DBは使いません(サーバを再起動すると初期化されます)。

route.ts の作成

Route Handlersを配置するために、app/api/tasks/ フォルダの中に route.ts を作ります。Visual Studio Codeのエクスプローラーで app フォルダを右クリックし、「新しいフォルダー」から api/tasks を作成し、その中に route.ts を新規作成します。

frontend-fetch-demo
├── app/
│   ├── api/                  ← このフォルダを作成
│   │   └── tasks/            ← このフォルダを作成
│   │       └── route.ts      ← このファイルを作成
│   ├── layout.tsx
│   ├── page.tsx
│   └── ...
└── ...

Route Handlersは、HTTPメソッドと同名の関数をexportすると、そのメソッドのハンドラーになります。書き方は以下のとおりです。

// app/api/エンドポイント名/route.ts
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  // 一覧取得などの処理
  return NextResponse.json(データ);
}

export async function POST(request: Request) {
  const body = await request.json();
  // 追加などの処理
  return NextResponse.json(データ, { status: ステータスコード });
}

以下は、タスク一覧を返しつつタスクを追加できるようにした route.ts の実装例です。作成した route.ts に以下の内容を記述して保存します。

import { NextResponse } from "next/server";

type Task = {
  id: number;
  title: string;
  done: boolean;
};

let tasks: Task[] = [
  { id: 1, title: "Next.jsのRoute Handlersを試す", done: false },
  { id: 2, title: "fetch APIでデータを取得する", done: false },
];
let nextId = 3;

export async function GET() {
  return NextResponse.json(tasks);
}

export async function POST(request: Request) {
  const body = await request.json();
  const title = typeof body?.title === "string" ? body.title.trim() : "";

  if (!title) {
    return NextResponse.json(
      { message: "title is required" },
      { status: 400 },
    );
  }

  const task: Task = { id: nextId++, title, done: false };
  tasks = [...tasks, task];
  return NextResponse.json(task, { status: 201 });
}

コードを解説します。

import { NextResponse } from "next/server";

NextResponse は、Route Handlersからのレスポンスを作るためのヘルパーです。NextResponse.json(データ) で、Content-Type: application/json を自動で付けたJSONレスポンスを返せます。

type Task = {
  id: number;
  title: string;
  done: boolean;
};

let tasks: Task[] = [ ... ];
let nextId = 3;

タスクをメモリ上の配列 tasks で保持します。ここに書いた Task 型を、あとでフロントエンド側でも使い回します。実運用ではDBに保存する部分ですが、本章は「フロントとAPIの通信」に集中するため、意図的にメモリ持ちにしています。開発サーバを再起動すると tasks の中身は初期状態に戻ります。

export async function GET() {
  return NextResponse.json(tasks);
}

GET という名前でエクスポートした関数が、GET /api/tasks のハンドラーになります。ここでは tasks 配列をそのままJSONで返しています。

export async function POST(request: Request) {
  const body = await request.json();
  const title = typeof body?.title === "string" ? body.title.trim() : "";

  if (!title) {
    return NextResponse.json(
      { message: "title is required" },
      { status: 400 },
    );
  }

  const task: Task = { id: nextId++, title, done: false };
  tasks = [...tasks, task];
  return NextResponse.json(task, { status: 201 });
}

POST のハンドラーでは、リクエストボディを request.json() で読み取ってから、title が空文字でないかを確認します。空なら 400 Bad Request を返し、そうでなければ新しいタスクを配列に追加してから 201 Created を返します。バリデーションを1行入れておくと、あとでフロントエンド側からの誤ったリクエストを再現しやすく、動作確認しやすくなります。

保存できたら、npm run dev を実行中のターミナルにコンパイルエラーが出ていないことを確認します。エラーが出ていなければ、Route Handler側のファイルは準備完了です。次にAPIが単体で動くかを curl から検証します。

curl での API 動作確認

route.tsの実装が終わったら、フロントエンドと連携する前に APIが単体で正しく動作するか を確認しておきます。ここで動作確認しておくと、後でフロントエンドから呼び出して正しく動作しなかったときに、原因の切り分けがしやすくなります。

Visual Studio Codeで新しいターミナルを開きます(既存のターミナルは npm run dev を実行中のため、そのままにします)。まずは一覧取得を確認します。

curl http://localhost:3000/api/tasks

以下のような実行結果が表示されます。

[{"id":1,"title":"Next.jsのRoute Handlersを試す","done":false},{"id":2,"title":"fetch APIでデータを取得する","done":false}]

続いてPOSTでタスクを1件追加します。

curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"title":"curlで追加したタスク"}'

以下のような実行結果が表示されます。

{"id":3,"title":"curlで追加したタスク","done":false}

もう一度GETすると、追加したタスクが増えていることを確認できます。

curl http://localhost:3000/api/tasks

以下のような実行結果が表示されます。

[{"id":1,"title":"Next.jsのRoute Handlersを試す","done":false},{"id":2,"title":"fetch APIでデータを取得する","done":false},{"id":3,"title":"curlで追加したタスク","done":false}]

3件目に curlで追加したタスク が表示されれば、Route HandlersのGET/POSTがどちらも正しく動作しています。

⚠️ Address already in use エラーが出る場合
npm run dev を別のターミナルですでに起動していないか確認してください。すでに起動していれば新たに起動する必要はなく、そのままブラウザで http://localhost:3000 を開けます。別プロセスが3000番ポートを掴んでいる場合は、そのプロセスを終了するか、npm run dev -- -p 3001 のように別ポートで起動します。

4. fetch API でのデータ取得

APIが動くことを確認できたので、次はブラウザ側から fetch でAPIを呼び出してタスク一覧を表示します。ここではまず、React標準の useEffect + useState の組み合わせで、ロード中・エラー・成功の3状態を自分で管理する書き方を体験します。

4.1 Client Component での一覧表示

Route Handlersを呼び出すコンポーネントを、app/tasks/page.tsx として作成します。Visual Studio Codeのエクスプローラーで app フォルダを右クリックし、「新しいフォルダー」から tasks を作成し、その中に page.tsx を新規作成します。

frontend-fetch-demo
├── app/
│   ├── api/
│   │   └── tasks/
│   │       └── route.ts
│   ├── tasks/                ← このフォルダを作成
│   │   └── page.tsx          ← このファイルを作成
│   ├── layout.tsx
│   └── page.tsx
└── ...

Client Componentから fetch を呼ぶときは、ファイル冒頭に "use client" を書き、useEffect の中で fetch を実行するのが基本の書き方になります。書き方は以下のとおりです。

"use client";

import { useEffect, useState } from "react";

export default function コンポーネント名() {
  const [データ, setデータ] = useState<型>(初期値);

  useEffect(() => {
    const load = async () => {
      const res = await fetch("エンドポイントURL");
      const data = await res.json();
      setデータ(data);
    };
    load();
  }, []);

  return (
    // 画面表示
  );
}

以下は、/api/tasks から一覧を取得して表示する実装例です。ロード中・エラーの状態管理とリクエスト中断まで含めた形にしています。作成した page.tsx に以下の内容を記述して保存します。

"use client";

import { useEffect, useState } from "react";

type Task = {
  id: number;
  title: string;
  done: boolean;
};

export default function TasksPage() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    const load = async () => {
      try {
        setIsLoading(true);
        setError(null);
        const res = await fetch("/api/tasks", { signal: controller.signal });
        if (!res.ok) {
          throw new Error(`HTTP ${res.status}`);
        }
        const data: Task[] = await res.json();
        setTasks(data);
      } catch (err) {
        if (err instanceof DOMException && err.name === "AbortError") {
          return;
        }
        setError(err instanceof Error ? err.message : "unknown error");
      } finally {
        setIsLoading(false);
      }
    };

    load();

    return () => controller.abort();
  }, []);

  if (isLoading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;

  return (
    <main style={{ padding: 24 }}>
      <h1>タスク一覧</h1>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            {task.title} {task.done ? "(完了)" : ""}
          </li>
        ))}
      </ul>
    </main>
  );
}

コードを解説します。

"use client";

冒頭の "use client" ディレクティブは、このファイルをClient Componentとして扱うようNext.jsに伝えます。useState / useEffect はブラウザ側のフックのため、Server Componentのままでは使えません。

const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

タスク本体・ロード中フラグ・エラーメッセージの3つを、それぞれ独立した状態として持ちます。シンプルな fetch を使うときは、この3つを自分で組み合わせて画面表示を切り替えるのが基本です。

useEffect(() => {
  const controller = new AbortController();

  const load = async () => {
    try {
      setIsLoading(true);
      setError(null);
      const res = await fetch("/api/tasks", { signal: controller.signal });
      if (!res.ok) {
        throw new Error(`HTTP ${res.status}`);
      }
      const data: Task[] = await res.json();
      setTasks(data);
    } catch (err) {
      if (err instanceof DOMException && err.name === "AbortError") {
        return;
      }
      setError(err instanceof Error ? err.message : "unknown error");
    } finally {
      setIsLoading(false);
    }
  };

  load();

  return () => controller.abort();
}, []);

useEffect の中で fetch を呼び、レスポンスを await res.json() でオブジェクトに変換して tasks に入れています。fetch の戻り値はPromiseで、.json() もPromiseを返すため、async / await で順に待ちます。

AbortController は、コンポーネントが画面から消えた(unmountされた)ときに進行中のリクエストを中断するためのオブジェクトです。useEffect のクリーンアップ関数で controller.abort() を呼ぶことで、ページを離れた瞬間にリクエストを止められます。中断された場合は AbortError として catch に入るため、エラー表示を出さずに早期returnで無視しています。

fetch はHTTPステータスが4xx/5xxでもPromiseがrejectされない仕様のため、if (!res.ok) の分岐で自分で例外を投げる必要があります。この点は誤りやすいので、覚えておくと役立ちます。

📝 fetchのエラーハンドリング
fetch は「ネットワーク自体が失敗した場合」だけPromiseをrejectします。HTTPステータスコードが 404 や 500 の場合でもrejectはされず、res.okfalse になるだけです。詳しくは Using the Fetch API - Checking response status(MDN) に「The promise returned by fetch() will reject on some errors, such as a network error or a bad scheme. However, if the server responds with an error like 404, then fetch() fulfills with a Response, so we have to check the status before we can read the response body.」と記載があります。
if (isLoading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error}</p>;

状態に応じて表示するJSXを切り替えます。読み込み中・エラー・成功の3パターンを、それぞれ別のJSXで返す形にしておくと分岐が読みやすくなります。

4.2 ブラウザでの一覧確認

コードを保存したら、ブラウザで http://localhost:3000/tasks を開きます。以下のように、一瞬「読み込み中...」と表示された後、Route Handlersで用意した2件のタスクが一覧表示されます。

「タスク一覧」の見出しと、初期データの Next.jsのRoute Handlersを試す / fetch APIでデータを取得する の2件が並んで表示されていれば、Client Componentからの fetch によるデータ取得は成功です。

⚠️ Failed to fetch と表示される場合
開発サーバ(npm run dev)が停止していると、fetch がネットワークエラーになります。まずターミナルで npm run dev が動き続けているかを確認し、必要に応じて再起動してから、ページをリロードしてください。

5. フォームでのデータ送信

一覧の表示ができたので、次は画面から POST でタスクを追加できるようにします。Reactのフォーム操作の基本である onSubmitevent.preventDefault()・入力state管理を組み合わせて、追加後には一覧を再取得して画面に反映するところまで作ります。

5.1 フォームと再取得の追加

一覧表示までは動くようになったので、次はフォームから POST /api/tasks を呼び出してタスクを追加できるようにし、追加直後に一覧を再取得して画面へ反映するところまでを1つのコンポーネントにまとめます。初回取得と再取得で fetch の呼び出しを二重に書かなくて済むよう、取得処理は loadTasks として関数化して共通化します。

fetch でJSONをPOSTするときは、第2引数に methodheadersbody を指定します。書き方は以下のとおりです。

const res = await fetch("エンドポイントURL", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ フィールド名: 値 }),
});

以下は、/api/tasks にタスクをPOSTし、成功したら loadTasks で一覧を再取得する形にまとめた実装例です。app/tasks/page.tsx を以下の内容に置き換えます。差分が多いため、ファイル全体を書き換える形にしています。

"use client";

import { useCallback, useEffect, useState } from "react";

type Task = {
  id: number;
  title: string;
  done: boolean;
};

export default function TasksPage() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [title, setTitle] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);

  const loadTasks = useCallback(async (signal?: AbortSignal) => {
    try {
      setIsLoading(true);
      setError(null);
      const res = await fetch("/api/tasks", { signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data: Task[] = await res.json();
      setTasks(data);
    } catch (err) {
      if (err instanceof DOMException && err.name === "AbortError") return;
      setError(err instanceof Error ? err.message : "unknown error");
    } finally {
      setIsLoading(false);
    }
  }, []);

  useEffect(() => {
    const controller = new AbortController();
    loadTasks(controller.signal);
    return () => controller.abort();
  }, [loadTasks]);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!title.trim()) return;

    try {
      setIsSubmitting(true);
      const res = await fetch("/api/tasks", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title }),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setTitle("");
      await loadTasks();
    } catch (err) {
      setError(err instanceof Error ? err.message : "unknown error");
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <main style={{ padding: 24 }}>
      <h1>タスク一覧</h1>

      <form onSubmit={handleSubmit} style={{ marginBottom: 16 }}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="新しいタスクを入力"
          disabled={isSubmitting}
        />
        <button type="submit" disabled={isSubmitting || !title.trim()}>
          {isSubmitting ? "送信中..." : "追加"}
        </button>
      </form>

      {isLoading && <p>読み込み中...</p>}
      {error && <p>エラー: {error}</p>}

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            {task.title} {task.done ? "(完了)" : ""}
          </li>
        ))}
      </ul>
    </main>
  );
}

追加したコードを解説します。

const loadTasks = useCallback(async (signal?: AbortSignal) => {
  ...
}, []);

一覧取得の処理を loadTasks として関数化しました。useCallback で包むことで、useEffect の依存配列に入れても毎レンダーで新しい関数が作られず、無限ループを防げます。POST後の再取得と初回取得の両方から同じ関数を呼び出せるようにしたのが最大の狙いです。

const [title, setTitle] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);

フォームの入力値 title と、送信中フラグ isSubmitting を追加しました。送信中は入力欄とボタンを disabled にして、二重送信を防ぎます。

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
  event.preventDefault();
  ...
};

onSubmit に渡すハンドラーの冒頭で event.preventDefault() を呼び出しています。これを書かないとブラウザ本来のフォーム送信(ページ全体のリロード)が走ってしまい、Reactの状態がリセットされます。フォームを扱う際の基本パターンとして押さえておきたい書き方です。

const res = await fetch("/api/tasks", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title }),
});

fetch の第2引数でHTTPメソッド・ヘッダー・ボディを指定します。JSONを送るときは、Content-Typeapplication/json にすることと、bodyJSON.stringify(...) で文字列化することがセットで必要です。ここを忘れるとRoute Handlers側の request.json() がパースに失敗します。

setTitle("");
await loadTasks();

POSTが成功したら、入力欄を空にしてから loadTasks() で一覧を再取得しています。APIが返してきた新規タスクを既存の taskspush する方が通信が1回減って効率的ですが、まずは「サーバからそのまま取り直す」ほうがフロントとサーバの整合性を意識せずに済むため、実装がシンプルになります。

5.2 フォームからのタスク追加

ブラウザで http://localhost:3000/tasks を開くと、以下のようにフォームと初期表示の2件のタスクが並んだ状態になります。

入力欄に 新規Task と入力して「追加」ボタンを押します。

送信が完了すると、入力したタスクが一覧の末尾に追加されます。この動きが確認できれば、POST → 再取得のフローが動いています。

💡 ポイント
Route Handlersが持つ tasks 配列はメモリ上のため、開発サーバを再起動すると初期の2件に戻ります。ページをリロードしただけでは配列は消えないので、動作確認中に困ることはあまりありませんが、「なぜかタスクが減った」と感じたときは npm run dev の再起動を疑ってみてください。

6. TanStack Query の導入

シンプルな fetch でも一覧取得と追加は実装できましたが、コード量が多く、useStateuseEffect の組み合わせで「ロード中/エラー/再取得/二重送信防止」を自前で書く必要がありました。同じ処理を TanStack Query で書き直すと、フックの呼び出しだけで大半の状態管理を任せられます。ここでは導入手順から useQuery / useMutation の書き換えまでを進めます。

6.1 TanStack Queryとは

TanStack Query(旧 React Query)は、Reactでサーバから取得したデータを扱うためのライブラリです。fetch そのものを置き換えるものではなく、fetch で取得したデータの キャッシュ・再取得・ロード状態管理 を統合的に管理してくれる立ち位置になります。公式には「サーバ状態管理ライブラリ」と紹介されており、useState で扱う「クライアント側の一時的な状態」とは別物として扱われます(Overview(TanStack Query公式ドキュメント) に「TanStack Query (formerly known as React Query) is often described as the missing data-fetching library for web applications」と記載があります)。

導入すると、以下のような機能が組み込みで手に入ります。

  • 同じ queryKey に対する取得結果をメモリキャッシュし、複数コンポーネントで共有できる
  • ウィンドウにフォーカスが戻ったタイミングなどで自動的に再取得できる
  • 一定時間キャッシュを鮮度あり(fresh)として扱い、無駄な再取得を減らせる
  • useMutationinvalidateQueries の組み合わせで「更新後に一覧を再取得」を宣言的に書ける

小さな画面では過剰な機能となることもありますが、複数画面で同じデータを共有する規模になると、シンプルな fetch に比べてコードが大幅に読みやすくなります。

6.2 TanStack Query のインストール

TanStack Queryはnpmパッケージとして配布されているため、プロジェクトに追加するにはまずインストールが必要です。開発サーバは以降のハンズオンでも起動したまま使うので、実行中のターミナルは停止せず、Visual Studio Codeで新しいターミナルを開いてインストールコマンドを実行します。プロジェクト直下で以下のコマンドを実行します。

npm install @tanstack/react-query

インストールが完了すると、package.jsondependencies に以下のように @tanstack/react-query が追加されます。バージョン部分はインストール時点の最新版になるため、値は変わる場合があります。

"dependencies": {
  ...
  "@tanstack/react-query": "^5.62.0"
},

6.3 QueryClientProvider の用意

TanStack Queryを使うには、アプリケーションのルートで QueryClientProvider を差し込む必要があります。ただし、QueryClientProvider はClient Component専用のため、Server Componentである app/layout.tsx から直接使うとエラーになります。そこで、Providerだけを切り出したClient Component を用意して、layout.tsx から呼び出す形にします。

app/providers.tsx を作成します。Visual Studio Codeのエクスプローラーで app フォルダを右クリックし、「新しいファイル」から providers.tsx を新規作成します。

frontend-fetch-demo
├── app/
│   ├── api/
│   │   └── tasks/
│   │       └── route.ts
│   ├── tasks/
│   │   └── page.tsx
│   ├── layout.tsx
│   ├── page.tsx
│   └── providers.tsx         ← このファイルを作成
└── ...

QueryClientProvider を使うときは、QueryClient を1つ作り、それを QueryClientProvider に渡して配下のコンポーネントを包む形が基本です。書き方は以下のとおりです。

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        // キャッシュや再取得の初期設定
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

以下は、staleTime を30秒、refetchOnWindowFocus をオフに設定した実装例です。作成した providers.tsx に以下の内容を記述して保存します。

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 30 * 1000,
            refetchOnWindowFocus: false,
          },
        },
      }),
  );

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

コードを解説します。

"use client";

QueryClientProvider はブラウザで動くContext Providerのため、このファイル全体をClient Componentとしてマークします。

const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 30 * 1000,
          refetchOnWindowFocus: false,
        },
      },
    }),
);

QueryClient はTanStack Queryのキャッシュ本体です。useState の初期値関数の中で作ることで、コンポーネントが再レンダーされてもインスタンスが1つに保たれます(トップレベルの new QueryClient() はReactの厳格な環境で二重生成される可能性があるため避けます)。

staleTime は「取得したデータが fresh とみなされる期間」で、この間はコンポーネントの再マウントがあってもネットワーク再取得がスキップされます。ここでは動作を追いやすくするために30秒に設定しています。refetchOnWindowFocus は、他のタブから戻ってきたときに自動再取得するかの設定で、この章ではオフにして動きを分かりやすくしています。

続いて、app/layout.tsx から Providers を呼び出します。app/layout.tsx を開き、以下の内容に置き換えます。

import type { Metadata } from "next";
import { Providers } from "./providers";
import "./globals.css";

export const metadata: Metadata = {
  title: "frontend-fetch-demo",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

コードを解説します。

import { Providers } from "./providers";

先ほど作成した Providers コンポーネントを読み込みます。layout.tsxproviders.tsx は同じ app/ 直下にあるため、相対パスの ./providers でimportできます。

<body>
  <Providers>{children}</Providers>
</body>

<body> の直下で children<Providers> で包んでいます。これで配下のすべてのページから useQuery / useMutation が使えるようになります。layout.tsx そのものはServer Componentのままにできるので、"use client" は書きません。Providerだけを切り出したClient Componentを噛ませる のがApp RouterでTanStack Queryを使うときの定番パターンです。

6.4 useQueryによる一覧取得の書き換え

Providerが差し込めたので、実際のページ側でも useState + useEffect の組み合わせを useQuery / useMutation に置き換えて、TanStack Queryの書き方を体験します。一覧取得と作成の両方をまとめて書き換え、シンプルな fetch で書いていた状態管理コードがどれだけ減るかを見比べられる形にします。

useQuery は取得系、useMutation は書き込み系に使います。書き方は以下のとおりです。

// 取得系: useQuery
const { data, isPending, isError, error } = useQuery({
  queryKey: ["キャッシュキー"],
  queryFn: 取得関数,
});

// 書き込み系: useMutation
const mutation = useMutation({
  mutationFn: (引数) => 書き込み関数(引数),
  onSuccess: () => {
    // 成功時の処理(例: invalidateQueriesで再取得を促す)
  },
});
mutation.mutate(引数);

以下は、/api/tasks に対する一覧取得と追加を useQuery / useMutation で書き直した実装例です。app/tasks/page.tsx を、TanStack Queryを使った以下の内容に置き換えます。

"use client";

import {
  useMutation,
  useQuery,
  useQueryClient,
} from "@tanstack/react-query";
import { useState } from "react";

type Task = {
  id: number;
  title: string;
  done: boolean;
};

async function fetchTasks(): Promise<Task[]> {
  const res = await fetch("/api/tasks");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

async function createTask(title: string): Promise<Task> {
  const res = await fetch("/api/tasks", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

export default function TasksPage() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState("");

  const {
    data: tasks,
    isPending,
    isError,
    error,
  } = useQuery({
    queryKey: ["tasks"],
    queryFn: fetchTasks,
  });

  const createMutation = useMutation({
    mutationFn: (newTitle: string) => createTask(newTitle),
    onSuccess: () => {
      setTitle("");
      queryClient.invalidateQueries({ queryKey: ["tasks"] });
    },
  });

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!title.trim()) return;
    createMutation.mutate(title);
  };

  return (
    <main style={{ padding: 24 }}>
      <h1>タスク一覧</h1>

      <form onSubmit={handleSubmit} style={{ marginBottom: 16 }}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="新しいタスクを入力"
          disabled={createMutation.isPending}
        />
        <button
          type="submit"
          disabled={createMutation.isPending || !title.trim()}
        >
          {createMutation.isPending ? "送信中..." : "追加"}
        </button>
      </form>

      {isPending && <p>読み込み中...</p>}
      {isError && <p>エラー: {error.message}</p>}

      <ul>
        {tasks?.map((task) => (
          <li key={task.id}>
            {task.title} {task.done ? "(完了)" : ""}
          </li>
        ))}
      </ul>
    </main>
  );
}

コードを解説します。

async function fetchTasks(): Promise<Task[]> {
  const res = await fetch("/api/tasks");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

TanStack Query自体はHTTP通信を行いません。「データを取ってくる関数」を自分で用意して、それを useQuery に渡す形になります。fetch を素で使うのはこれまでと同じで、useQuery が担うのはその結果の管理です。

const {
  data: tasks,
  isPending,
  isError,
  error,
} = useQuery({
  queryKey: ["tasks"],
  queryFn: fetchTasks,
});

useQuery にキャッシュのキー(queryKey)と、取得関数(queryFn)を渡します。queryKey は文字列や数値の配列で、同じキーで呼ばれたクエリは1つのキャッシュを共有します。返り値の data が成功時のデータ、isPending がロード中フラグ、isError / error がエラー情報です。 useState + useEffect で書いていた3つの状態が、フック1個の戻り値になっているのが分かります。

const createMutation = useMutation({
  mutationFn: (newTitle: string) => createTask(newTitle),
  onSuccess: () => {
    setTitle("");
    queryClient.invalidateQueries({ queryKey: ["tasks"] });
  },
});

書き込み系の処理は useMutation を使います。mutationFn に「実行する関数」を渡し、成功時の処理を onSuccess に書きます。queryClient.invalidateQueries({ queryKey: ["tasks"] }) は「tasks キーのキャッシュを古いものとしてマークし、再取得を促す」動きになります。これで useQuery 側は自動的に再取得 され、一覧が最新化されます。シンプルな fetch では loadTasks() を明示的に呼び直していた部分が、宣言的な invalidateQueries に置き換わりました。

createMutation.mutate(title);

useMutation の実行は .mutate(引数) で行います。 .mutate は結果を待たずに戻るため、状態は createMutation.isPending などから拾います。.mutateAsync(...) を使うとPromiseとして待つこともできますが、通常は .mutate で十分です。

6.5 ブラウザでの動作確認

ブラウザで http://localhost:3000/tasks を開くと、初回表示で「読み込み中...」と表示された後、初期表示の2件のタスクが並んだ状態になります。

入力欄に 新規Task と入力して「追加」ボタンを押します。

送信が完了すると、入力したタスクが一覧の末尾に追加されます。この動きが確認できれば、TanStack Query による取得・送信が正しく動いています。

シンプルな fetch 版と比べて、コンポーネントのコード量が減り、状態管理のロジックがフックの呼び出しにまとまっているのが体感できるはずです。

💡 ポイント
staleTime: 30 * 1000 の効果で、30秒以内に別のページから戻ってきても再取得は走りません。挙動を試したい場合は、staleTime0 に落とすと、コンポーネントがマウントされるたびに再取得されるようになります。設定値ひとつで挙動を切り替えられるのが TanStack Query の魅力の一つです。

7. fetchとTanStack Queryの使い分け

同じ機能をシンプルな fetchTanStack Query の両方で書いたので、ここでどちらをどう選べばよいかを整理します。片方が万能というわけではなく、規模や画面数によって答えが変わります。

まず判断軸ごとの向き不向きを表にまとめます。

判断軸 シンプルな fetch が向く TanStack Query が向く
画面数 1〜2画面のシンプルなアプリケーションで完結する 複数画面で同じデータを共有する必要がある
データの更新頻度 初回1回だけ取得すれば十分な静的データを扱う 頻繁に更新され、他ユーザの変更も反映したい
キャッシュの必要性 毎回サーバに問い合わせても問題ない 同じデータの再取得を減らして体感速度を上げたい
コード量 1画面あたり数十行で収まる 状態管理コードを繰り返し書きたくない
チーム開発 個人プロジェクトや学習用途で使う 複数人が共通の書き方でデータ取得を書きたい

Next.js特有の視点も加えておきます。App RouterではServer Componentから直接データを取得できるため、SEOに載せたい・初期表示を速くしたい画面はServer Component側で fetch するのが第一選択になります。一方で、ボタン操作で更新が走るような 画面内の対話 は、どうしてもClient Componentで扱う場面が多くなり、そこでは TanStack Query が効きやすくなります。

まとめると、以下のような使い分けの目安になります。

  • 最初に扱うときはシンプルな fetch で仕組みを掴む
  • 画面や機能が増えて「同じ取得コードを何度も書いている」「更新後の再取得ロジックが散らばっている」と感じ始めたら TanStack Query に切り替える
  • Server Componentで賄える画面はまずServer Componentで書き、対話が必要な部分だけClient Component + TanStack Query に切り出す

「先にすべて TanStack Query で書く」よりも、シンプルな fetch で書き始めて痛みが出てから乗り換える方が、TanStack Query が解決している課題も体感でき、書き方の違いも身につきます。

8. まとめ

この章では、Next.jsからのAPI呼び出しについて、Route HandlersでのAPI実装からシンプルな fetch による取得・送信、TanStack Queryへの書き換えまでを学びつつ、実際にコードで扱いを確かめました。

  • Next.jsのRoute Handlersで GET /api/tasksPOST /api/tasks を実装し、curl で単体動作を確認できる
  • Client Componentからシンプルな fetch でタスク一覧を取得し、useStateuseEffect でロード中・エラー・成功の状態を管理できる
  • フォームからPOSTでタスクを追加し、追加後に loadTasks() を呼び直して一覧を再取得する構成を作れる
  • TanStack Queryを導入し、useQuery / useMutation / invalidateQueries で同じ処理を宣言的に書き直せる
  • シンプルな fetch とTanStack Queryの向き不向きを整理し、規模に応じて選ぶ判断軸を持てる

次の章では、Next.jsでのログイン機能の実装を学びつつ、Cookieでログイン状態を保持し、ロール別のUI出し分けと保護ページの実装を実際に体験します。

この教材は役に立ちましたか?

いいねをたくさんいただけると、制作者の励みになり、より多くのセクションが作れるようになります。

感想を一言(任意)

いただいたコメントは次の制作のヒントになります。ぜひお気軽にご投稿ください。

このコメントは他の受講生には公開されません。DevOps Camp運営が、教材改善のために確認します。

0 / 2000