第11章: API 通信

ほとんどの Web アプリケーションは、サーバーからデータを取得したり、ユーザーの入力をサーバーに送信したりします。 この章では、React で API 通信を行う方法を解説します。 fetch API と async/await を使ったデータ取得、 ローディング・エラー状態の管理、そして Tomcat バックエンドとの連携時の注意点を学びます。

API 通信の基本

React はあくまで UI ライブラリであり、API 通信の仕組みは含まれていません。 ブラウザ標準の fetch API を使ってサーバーと通信します。

典型的な API 通信の流れは以下の通りです。

  1. コンポーネントがマウントされる(画面に表示される)
  2. useEffect 内で API リクエストを送信する
  3. レスポンスを受け取り、State にデータを格納する
  4. State の更新によりコンポーネントが再レンダーされ、データが表示される

fetch API

fetch はブラウザに組み込まれた HTTP リクエスト用の関数です。 Promise を返すので、async/await と組み合わせて使用します。

fetch の基本
// GET リクエスト
const response = await fetch('/api/users');
const data = await response.json();

// POST リクエスト
const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: '田中太郎' }),
});

fetch は HTTP エラー(404, 500 など)で例外をスローしません。 ネットワークエラー(サーバーに接続できないなど)の場合のみ例外が発生します。 HTTP エラーを検知するには、response.ok(ステータスコード 200-299 で true)を 確認する必要があります。

fetch のエラーハンドリング
async function fetchData(url) {
  const response = await fetch(url);

  // HTTP エラーチェック(fetch は自動で throw しない)
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  return response.json();
}

useEffect でのデータ取得

コンポーネントのマウント時にデータを取得する基本パターンです。 useEffect の中で async 関数を定義して呼び出します。

useEffect のコールバック関数自体を async にしてはいけません。 useEffect はクリーンアップ関数(または undefined)の返却を期待しますが、 async 関数は Promise を返すため、React が正しくクリーンアップを処理できません。 代わりに、内部で async 関数を定義して即座に呼び出します。

基本的なデータ取得パターン
import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    // NG: useEffect のコールバック自体を async にしない
    // useEffect(async () => { ... })  ← これはダメ

    // OK: 内部で async 関数を定義して呼び出す
    async function fetchUsers() {
      const response = await fetch('/api/users');
      const data = await response.json();
      setUsers(data);
    }

    fetchUsers();
  }, []);  // 空配列: マウント時に 1 回だけ実行

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

ローディングとエラー処理

実際のアプリケーションでは、データの読み込み中やエラー発生時に適切な UI を表示する必要があります。 loadingerror の State を追加して管理しましょう。

ローディング・エラー対応
import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;

    async function fetchUsers() {
      try {
        const response = await fetch('/api/users');
        if (!response.ok) {
          throw new Error(`サーバーエラー: ${response.status}`);
        }
        const data = await response.json();
        if (!ignore) {
          setUsers(data);
        }
      } catch (err) {
        if (!ignore) {
          setError(err.message);
        }
      } finally {
        if (!ignore) {
          setLoading(false);
        }
      }
    }

    fetchUsers();

    return () => {
      ignore = true;
    };
  }, []);

  if (loading) return <p>読み込み中...</p>;
  if (error)   return <p>エラーが発生しました: {error}</p>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name} - {user.email}</li>
      ))}
    </ul>
  );
}

ignore フラグによるレースコンディション対策: コンポーネントがアンマウントされた後や、依存値が変わった後に古いレスポンスが State にセットされるのを防ぎます。これは React 公式ドキュメントでも推奨されている定番パターンです。

POST リクエスト

データの作成や更新には POST / PUT / PATCH リクエストを使用します。 フォーム送信のタイミングで API を呼び出し、結果に応じて UI を更新します。

POST リクエストの例
import { useState } from 'react';

function CreateUser() {
  const [name, setName] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);
    setError(null);

    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ name }),
      });

      if (!response.ok) {
        throw new Error(`送信に失敗しました: ${response.status}`);
      }

      const newUser = await response.json();
      console.log('作成されたユーザー:', newUser);
      setName('');  // フォームをリセット
    } catch (err) {
      setError(err.message);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="ユーザー名"
        disabled={submitting}
      />
      <button type="submit" disabled={submitting || !name.trim()}>
        {submitting ? '作成中...' : '作成'}
      </button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </form>
  );
}

CRUD 操作の実装

実際のアプリケーションでは、データの作成 (Create)読み取り (Read)更新 (Update)削除 (Delete) を組み合わせて実装します。

操作HTTP メソッドURL 例説明
一覧取得GET/api/users全ユーザーの取得
詳細取得GET/api/users/1ID=1 のユーザーを取得
作成POST/api/users新規ユーザーの作成
更新PUT / PATCH/api/users/1ID=1 のユーザーを更新
削除DELETE/api/users/1ID=1 のユーザーを削除
CRUD 操作を含むユーザー管理
import { useState, useEffect } from 'react';

const API_URL = '/api/users';

function UserManager() {
  const [users, setUsers] = useState([]);
  const [newName, setNewName] = useState('');
  const [loading, setLoading] = useState(true);

  // Read: 一覧取得
  useEffect(() => {
    fetch(API_URL)
      .then(res => res.json())
      .then(data => setUsers(data))
      .finally(() => setLoading(false));
  }, []);

  // Create: 新規作成
  async function handleCreate() {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: newName }),
    });
    const created = await response.json();
    setUsers(prev => [...prev, created]);
    setNewName('');
  }

  // Delete: 削除
  async function handleDelete(id) {
    await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
    setUsers(prev => prev.filter(user => user.id !== id));
  }

  if (loading) return <p>読み込み中...</p>;

  return (
    <div>
      <h2>ユーザー管理</h2>

      <div>
        <input
          value={newName}
          onChange={(e) => setNewName(e.target.value)}
          placeholder="新しいユーザー名"
        />
        <button onClick={handleCreate} disabled={!newName.trim()}>追加</button>
      </div>

      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name}
            <button onClick={() => handleDelete(user.id)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

CORS とプロキシ設定

開発時、React の開発サーバー(Vite: localhost:5173)と バックエンドの API サーバー(Tomcat: localhost:8080)は ポートが異なるため、ブラウザの同一オリジンポリシーにより リクエストがブロックされます。これが CORS(Cross-Origin Resource Sharing)の問題です。

解決策 1: Vite のプロキシ設定(開発時推奨)

Vite の設定でプロキシを定義すると、開発サーバーが API リクエストを代理して転送します。 ブラウザから見ると同一オリジンへのリクエストになるため、CORS の問題が発生しません。

vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      // /api で始まるリクエストを Tomcat に転送
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})

この設定により、アプリケーションコード内で fetch('/api/users') と書くだけで、 開発時は Vite が http://localhost:8080/api/users にリクエストを転送してくれます。 本番環境(Tomcat に直接デプロイ)では同一オリジンになるため、プロキシは不要です。

解決策 2: バックエンド側で CORS ヘッダーを設定

Tomcat のバックエンドで CORS ヘッダーを設定する方法もあります。 web.xml で Tomcat 標準の CORS フィルターを有効にします。

WEB-INF/web.xml(CORS フィルター)
<filter>
  <filter-name>CorsFilter</filter-name>
  <filter-class>org.apache.catalina.filters.CorsFilter</filter-class>
  <init-param>
    <param-name>cors.allowed.origins</param-name>
    <param-value>http://localhost:5173</param-value>
  </init-param>
  <init-param>
    <param-name>cors.allowed.methods</param-name>
    <param-value>GET,POST,PUT,DELETE,OPTIONS</param-value>
  </init-param>
  <init-param>
    <param-name>cors.allowed.headers</param-name>
    <param-value>Content-Type,Authorization</param-value>
  </init-param>
</filter>

<filter-mapping>
  <filter-name>CorsFilter</filter-name>
  <url-pattern>/api/*</url-pattern>
</filter-mapping>

開発時は Vite のプロキシ設定が手軽でおすすめです。 本番環境では React と API が同一の Tomcat 上で動作することが多く、 CORS は問題になりません。CORS フィルターは API を別ドメインで公開する場合に使用します。

カスタムフックで共通化

API 通信のパターン(loading / error / data の管理)は繰り返し使うため、 カスタムフックに切り出すと便利です。

hooks/useApi.js
import { useState, useCallback } from 'react';

function useApi() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const request = useCallback(async (url, options = {}) => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, {
        headers: { 'Content-Type': 'application/json' },
        ...options,
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();
      return data;
    } catch (err) {
      setError(err.message);
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  return { request, loading, error };
}

export default useApi;
使用例
import useApi from './hooks/useApi';

function UserPage() {
  const { request, loading, error } = useApi();
  const [users, setUsers] = useState([]);

  useEffect(() => {
    request('/api/users')
      .then(data => setUsers(data))
      .catch(() => {});  // エラーは useApi 内で管理
  }, [request]);

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

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

ベストプラクティス

項目推奨事項
ローディング表示 データ取得中は必ずローディング UI を表示し、ユーザーに待機状態を伝える
エラーハンドリング response.ok を確認し、ネットワークエラーと HTTP エラーの両方を処理する
レースコンディション ignore フラグやAbortController で古いリクエストの結果を破棄する
二重送信の防止 submitting State でボタンを disabled にし、連続クリックを防ぐ
API URL の管理 環境変数(import.meta.env.VITE_API_URL)でベース URL を管理する
共通化 API 通信のロジックはカスタムフックや API クライアントモジュールに集約する

本番環境で React(フロントエンド)と Java(バックエンド)を同じ Tomcat にデプロイする場合、 API のパスは相対パス(/api/users)で記述するのが一般的です。 これにより、CORS の問題を回避でき、環境ごとの設定変更が最小限になります。