はじめに

前回の記事では、useEffectを使った副作用処理とライフサイクルの理解について解説しました。本記事では、その知識を活用して実際のWebアプリケーション開発に欠かせない「API連携」について詳しく解説します。

現代のWebアプリケーションでは、外部APIからデータを取得して表示することが一般的です。天気情報、ユーザーデータ、商品リストなど、多くの情報はサーバーから動的に取得されます。ReactでこれらのAPIと連携するには、fetchaxiosといったHTTPクライアントを使用し、useEffectで適切にデータを取得する必要があります。

本記事を読むことで、以下のことができるようになります。

  • fetchを使った基本的なAPI呼び出し
  • axiosを使った効率的なデータ取得
  • ローディング・エラー状態の適切な管理
  • レースコンディションの理解と対策
  • カスタムフックによるデータ取得ロジックの再利用

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • Viteで作成したReactプロジェクト(TypeScript推奨)
  • VS Code(推奨)

前提知識

  • useStateの基本的な使い方
  • useEffectの基本構文と依存配列
  • async/awaitによる非同期処理の基礎
  • JSONデータの構造

fetchを使ったAPI呼び出しの基本

fetchとは

fetchは、ブラウザに標準で搭載されているHTTPリクエストを行うためのAPIです。追加のライブラリをインストールすることなく、すぐに使用できます。Promiseベースで設計されており、async/await構文と組み合わせて使用することが一般的です。

基本構文

1
2
const response = await fetch('https://api.example.com/data');
const data = await response.json();

fetchは2段階の処理が必要です。

  1. fetch()でリクエストを送信し、Responseオブジェクトを取得
  2. response.json()でレスポンスボディをJSONとしてパース

ReactでのfetchとuseEffectの組み合わせ

Reactでfetchを使用する際は、useEffectと組み合わせてコンポーネントのマウント時にデータを取得します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

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

  useEffect(() => {
    async function fetchUsers() {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');
      const data = await response.json();
      setUsers(data);
    }

    fetchUsers();
  }, []);

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

ポイントは以下の通りです。

  • useEffect内で直接async関数を渡すことはできない(useEffectはクリーンアップ関数のみを返すことができるため)
  • useEffect内部でasync関数を定義し、即座に呼び出すパターンを使用
  • 依存配列を空[]にすることで、マウント時のみ実行

ローディング・エラー状態の管理

3つの状態を管理する

実際のアプリケーションでは、データ取得中の状態を適切にユーザーに伝える必要があります。以下の3つの状態を管理しましょう。

  1. loading: データ取得中かどうか
  2. error: エラーが発生したかどうか
  3. data: 取得したデータ
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { useState, useEffect } from 'react';

interface Post {
  id: number;
  title: string;
  body: string;
}

function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchPosts() {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch('https://jsonplaceholder.typicode.com/posts');
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const data = await response.json();
        setPosts(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : '予期せぬエラーが発生しました');
      } finally {
        setLoading(false);
      }
    }

    fetchPosts();
  }, []);

  if (loading) {
    return <div className="loading">読み込み中...</div>;
  }

  if (error) {
    return <div className="error">エラー: {error}</div>;
  }

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
}

レスポンスのエラーチェック

fetchは、ネットワークエラーが発生した場合のみPromiseをrejectします。HTTPステータスコードが4xxや5xxであっても、Promiseは正常に解決されます。そのため、response.okプロパティを確認してHTTPエラーを検出する必要があります。

1
2
3
4
5
6
const response = await fetch(url);

if (!response.ok) {
  // 4xx, 5xxエラーの処理
  throw new Error(`HTTP error! status: ${response.status}`);
}

レースコンディションへの対策

レースコンディションとは

レースコンディションは、非同期処理の完了順序が保証されないために起こる問題です。例えば、ユーザーIDを変更して連続でAPIを呼び出した場合、後から呼び出したリクエストが先に完了することがあります。

1
2
3
4
1. ユーザーA のデータをリクエスト
2. ユーザーB のデータをリクエスト(ユーザーが素早く切り替えた)
3. ユーザーB のレスポンスが到着 → 画面にユーザーBを表示
4. ユーザーA のレスポンスが到着 → 画面がユーザーAに戻ってしまう(バグ)

ignoreフラグによる対策

React公式ドキュメントで推奨されている方法は、クリーンアップ関数でignoreフラグを設定し、古いリクエストの結果を無視することです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
}

interface UserProfileProps {
  userId: number;
}

function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

    async function fetchUser() {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`
        );
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const data = await response.json();
        
        // クリーンアップ後は状態を更新しない
        if (!ignore) {
          setUser(data);
        }
      } catch (err) {
        if (!ignore) {
          setError(err instanceof Error ? err.message : '予期せぬエラーが発生しました');
        }
      } finally {
        if (!ignore) {
          setLoading(false);
        }
      }
    }

    fetchUser();

    // クリーンアップ関数
    return () => {
      ignore = true;
    };
  }, [userId]);

  if (loading) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return <div>エラー: {error}</div>;
  }

  if (!user) {
    return <div>ユーザーが見つかりません</div>;
  }

  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Phone: {user.phone}</p>
    </div>
  );
}

AbortControllerによるリクエストのキャンセル

より効率的な方法として、AbortControllerを使ってリクエスト自体をキャンセルすることもできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
useEffect(() => {
  const abortController = new AbortController();

  async function fetchUser() {
    try {
      setLoading(true);
      setError(null);
      
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/users/${userId}`,
        { signal: abortController.signal }
      );
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const data = await response.json();
      setUser(data);
    } catch (err) {
      // AbortErrorは無視
      if (err instanceof Error && err.name === 'AbortError') {
        return;
      }
      setError(err instanceof Error ? err.message : '予期せぬエラーが発生しました');
    } finally {
      setLoading(false);
    }
  }

  fetchUser();

  return () => {
    abortController.abort();
  };
}, [userId]);

axiosを使ったデータ取得

axiosとは

axiosは、Promise ベースのHTTPクライアントライブラリです。fetchと比較して以下のメリットがあります。

機能 fetch axios
JSONの自動変換 手動で.json()が必要 自動
HTTPエラーの処理 手動でresponse.ok確認 自動でthrow
リクエストのキャンセル AbortController CancelToken / AbortController
インターセプター なし あり
タイムアウト設定 手動実装 組み込み
リクエスト・レスポンス変換 手動 組み込み

axiosのインストール

1
npm install axios

TypeScriptを使用している場合、型定義は含まれているため追加のインストールは不要です。

axiosの基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { useState, useEffect } from 'react';
import axios from 'axios';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

    async function fetchTodos() {
      try {
        setLoading(true);
        setError(null);
        
        // axiosはレスポンスを自動でJSONにパースする
        const response = await axios.get<Todo[]>(
          'https://jsonplaceholder.typicode.com/todos',
          { signal: abortController.signal }
        );
        
        // response.dataにデータが格納される
        setTodos(response.data.slice(0, 10));
      } catch (err) {
        if (axios.isCancel(err)) {
          return;
        }
        if (axios.isAxiosError(err)) {
          setError(err.response?.data?.message || err.message);
        } else {
          setError('予期せぬエラーが発生しました');
        }
      } finally {
        setLoading(false);
      }
    }

    fetchTodos();

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

  if (loading) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return <div>エラー: {error}</div>;
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input type="checkbox" checked={todo.completed} readOnly />
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

axiosインスタンスの作成

複数のAPIエンドポイントを使用する場合、共通の設定を持つaxiosインスタンスを作成すると便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// src/lib/api.ts
import axios from 'axios';

const api = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// リクエストインターセプター
api.interceptors.request.use(
  (config) => {
    // 認証トークンの追加など
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// レスポンスインターセプター
api.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    // 共通のエラーハンドリング
    if (error.response?.status === 401) {
      // 認証エラー時の処理
      console.log('認証エラー: ログインが必要です');
    }
    return Promise.reject(error);
  }
);

export default api;
1
2
3
4
// コンポーネントでの使用
import api from './lib/api';

const response = await api.get<User[]>('/users');

カスタムフックによるデータ取得の再利用

useFetchカスタムフックの実装

データ取得のロジックをカスタムフックに抽出することで、コードの再利用性が向上します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/hooks/useFetch.ts
import { useState, useEffect } from 'react';

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [refetchIndex, setRefetchIndex] = useState(0);

  const refetch = () => {
    setRefetchIndex((prev) => prev + 1);
  };

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

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url);

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

        const json = await response.json();

        if (!ignore) {
          setData(json);
        }
      } catch (err) {
        if (!ignore) {
          setError(err instanceof Error ? err.message : '予期せぬエラーが発生しました');
        }
      } finally {
        if (!ignore) {
          setLoading(false);
        }
      }
    }

    fetchData();

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

  return { data, loading, error, refetch };
}

export default useFetch;

カスタムフックの使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import useFetch from './hooks/useFetch';

interface Post {
  id: number;
  title: string;
  body: string;
}

function PostList() {
  const { data: posts, loading, error, refetch } = useFetch<Post[]>(
    'https://jsonplaceholder.typicode.com/posts'
  );

  if (loading) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return (
      <div>
        <p>エラー: {error}</p>
        <button onClick={refetch}>再試行</button>
      </div>
    );
  }

  return (
    <div>
      <button onClick={refetch}>更新</button>
      <ul>
        {posts?.slice(0, 10).map(post => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

実践的なサンプル: ユーザー検索アプリ

ここまで学んだ内容を活用して、GitHub APIを使ったユーザー検索アプリを作成しましょう。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';

interface GitHubUser {
  id: number;
  login: string;
  avatar_url: string;
  html_url: string;
  public_repos: number;
}

interface SearchResult {
  total_count: number;
  items: GitHubUser[];
}

function GitHubUserSearch() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');
  const [users, setUsers] = useState<GitHubUser[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [totalCount, setTotalCount] = useState(0);

  // 入力値のデバウンス処理
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 500);

    return () => {
      clearTimeout(timer);
    };
  }, [query]);

  // API呼び出し
  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setUsers([]);
      setTotalCount(0);
      return;
    }

    const abortController = new AbortController();

    async function searchUsers() {
      try {
        setLoading(true);
        setError(null);

        const response = await axios.get<SearchResult>(
          `https://api.github.com/search/users`,
          {
            params: {
              q: debouncedQuery,
              per_page: 10,
            },
            signal: abortController.signal,
          }
        );

        setUsers(response.data.items);
        setTotalCount(response.data.total_count);
      } catch (err) {
        if (axios.isCancel(err)) {
          return;
        }
        if (axios.isAxiosError(err)) {
          if (err.response?.status === 403) {
            setError('API制限に達しました。しばらく待ってから再試行してください。');
          } else {
            setError(err.message);
          }
        } else {
          setError('予期せぬエラーが発生しました');
        }
      } finally {
        setLoading(false);
      }
    }

    searchUsers();

    return () => {
      abortController.abort();
    };
  }, [debouncedQuery]);

  return (
    <div className="github-search">
      <h1>GitHub ユーザー検索</h1>
      
      <div className="search-box">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="ユーザー名を入力..."
        />
      </div>

      {loading && <div className="loading">検索中...</div>}
      
      {error && <div className="error">{error}</div>}

      {!loading && !error && totalCount > 0 && (
        <p className="result-count">{totalCount.toLocaleString()} 件の結果</p>
      )}

      <ul className="user-list">
        {users.map(user => (
          <li key={user.id} className="user-card">
            <img src={user.avatar_url} alt={user.login} width={50} height={50} />
            <div className="user-info">
              <a href={user.html_url} target="_blank" rel="noopener noreferrer">
                {user.login}
              </a>
              <span>公開リポジトリ: {user.public_repos}</span>
            </div>
          </li>
        ))}
      </ul>

      {!loading && !error && debouncedQuery && users.length === 0 && (
        <p>ユーザーが見つかりませんでした</p>
      )}
    </div>
  );
}

export default GitHubUserSearch;

期待される動作

  1. テキストボックスにユーザー名を入力
  2. 500ms のデバウンス後にGitHub APIを呼び出し
  3. 検索中はローディング表示
  4. 結果をカード形式で表示
  5. エラー発生時はエラーメッセージを表示

fetchとaxiosの使い分け

fetchを選ぶべき場合

  • 追加の依存関係を増やしたくない
  • シンプルなGETリクエストのみ
  • ブラウザ標準APIを使いたい
  • バンドルサイズを最小限に抑えたい

axiosを選ぶべき場合

  • 複雑なAPI連携が必要
  • インターセプターでリクエスト・レスポンスを加工したい
  • タイムアウトやリトライ処理が必要
  • 一貫したエラーハンドリングを実装したい

より高度なデータ取得ライブラリ

本番環境のアプリケーションでは、以下のようなデータ取得ライブラリの使用を検討してください。

ライブラリ 特徴
TanStack Query (React Query) キャッシュ、再検証、楽観的更新
SWR 軽量、stale-while-revalidate戦略
RTK Query Redux Toolkitと統合

これらのライブラリを使用することで、キャッシュ管理、バックグラウンド更新、楽観的更新などの高度な機能を簡単に実装できます。

よくある間違いと解決策

useEffectの直接async化

1
2
3
4
5
// 間違い: useEffectを直接async関数にしている
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

useEffectはクリーンアップ関数のみを返すことができます。async関数はPromiseを返すため、上記のコードはエラーになります。

1
2
3
4
5
6
7
8
9
// 正解: useEffect内部でasync関数を定義して呼び出す
useEffect(() => {
  async function fetchData() {
    const data = await getData();
    setData(data);
  }
  
  fetchData();
}, []);

クリーンアップの忘れ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 問題: クリーンアップがないためレースコンディションが発生する可能性
useEffect(() => {
  async function fetchUser() {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUser(data);
  }
  
  fetchUser();
}, [userId]);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 解決: ignoreフラグまたはAbortControllerでクリーンアップ
useEffect(() => {
  let ignore = false;
  
  async function fetchUser() {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    if (!ignore) {
      setUser(data);
    }
  }
  
  fetchUser();
  
  return () => {
    ignore = true;
  };
}, [userId]);

まとめ

本記事では、ReactでのAPI連携について解説しました。

  • fetch: ブラウザ標準のHTTPクライアント。シンプルなリクエストに最適
  • axios: 高機能なHTTPクライアント。複雑なAPI連携に便利
  • 状態管理: loading、error、dataの3つの状態を適切に管理
  • レースコンディション対策: ignoreフラグまたはAbortControllerで対策
  • カスタムフック: データ取得ロジックを再利用可能に

実際のアプリケーション開発では、TanStack QueryやSWRなどのデータ取得ライブラリの使用も検討してください。これらのライブラリを使うことで、キャッシュ管理やエラーハンドリングがより簡単になります。

動作確認用リンク

本記事のサンプルコードは以下の環境で動作を確認できます。

次に読む記事

参考リンク