404 Not Found

404 Not Found


nginx

HTTPリクエストとデータの取得

トムがユーザー管理ページでユーザー一覧を取得するためにAPIを呼び出した際、3つの問題が発生しました。第一に、ページを素早く切り替えると、前のリクエストからの応答が次のリクエストのデータを上書きしてしまう(レースコンディション)こと。第二に、ネットワークエラーが発生すると、エラーメッセージを表示することなくページが単に真っ白になってしまうこと。そして最後に、すべてのページで「読み込み中」「エラー」「データ表示」という3つの状態ロジックを繰り返し実装しなければならなかったことです。彼は、リクエストのライフサイクル全体を管理するためには、統一されたHTTPリクエストソリューションが必要であることに気づいた。


1. 学習内容


2. 概念図

100%
flowchart LR
    A[Submit a Request] --> B{loading = true}
    B --> C[Request in progress]
    C --> D{Success/Failure?}
    D -->|Success| E[data = Response<br/>loading = false<br/>error = null]
    D -->|Failure| F[error = Error Message<br/>loading = false<br/>data = null]
    E --> G[Rendering Data]
    F --> H{Can I try again??}
    H -->|is | A
    H -->|No| I[Display Error UI]
    G --> J[Component Uninstallation?]
    J -->|is | K[AbortController<br/>Cancel Request]
    style B fill:#fff3e0,stroke:#f57c00
    style D fill:#e1f5fe,stroke:#0288d1
    style K fill:#ffcdd2,stroke:#d32f2f

リクエストのライフサイクル:読み込み開始 → リクエストの実行 → 成功時はデータが割り当てられ、失敗時はエラーが割り当てられる → コンポーネントがアンマウントされると、保留中のリクエストはキャンセルされる。


3. 実際の事例

トムのユーザー管理ページには、以下の要件があります。ページを開いた際にユーザー一覧が読み込まれること、一覧の読み込み中はローディングアイコンが表示されること、読み込みに失敗した場合はエラーメッセージと「再試行」ボタンが表示されること、ユーザーが一覧表示と詳細表示を素早く切り替えてもデータが乱れないこと、そして401エラーが発生した場合は、すべてのAPIリクエストが自動的にログインページにリダイレクトされること。

(1) 3州モード

ReactでHTTPリクエストを行うための最も基本的な方法は、3つの状態変数を管理することです:

JSX
const [data, setData] = useState(null)     // Success Data
const [loading, setLoading] = useState(true) // Loading...
const [error, setError] = useState(null)    // Error Message

なぜ3つの状態の区別が必要なのでしょうか? それは、UIが3つの異なる状況において、まったく異なるコンテンツを表示する必要があるからです:

ステータス データ 読み込み中 エラー UIの動作
読み込み中 null true null スピナーまたはプレースホルダー画面を表示
成功 データ false null データ一覧を表示
失敗 null false エラーメッセージ エラーメッセージと再試行ボタンを表示
データなし [] false null 「データがありません」というメッセージを表示

(1) ▶ サンプル:fetch および useEffect を用いた 3 状態管理

JSX
import { useState, useEffect } from 'react'

function UserList() {
  const [users, setUsers] = useState([])     // Data
  const [loading, setLoading] = useState(true) // Loaded state
  const [error, setError] = useState(null)    // Error State

  function fetchUsers() {
    setLoading(true)
    setError(null)

    fetch('https://jsonplaceholder.typicode.com/users')
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}:${response.statusText}`)
        }
        return response.json()
      })
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }

  useEffect(() => {
    fetchUsers()
  }, [])

  // --- Three-State Rendering ---
  if (loading) {
    return (
      <div className="loading-state">
        <div className="spinner" />
        <p>Loading user data...</p>
      </div>
    )
  }

  if (error) {
    return (
      <div className="error-state">
        <p className="error-icon">&#x26A0;</p>
        <p>Failed to load:{error}</p>
        <button onClick={fetchUsers}>Retry</button>
      </div>
    )
  }

  if (users.length === 0) {
    return (
      <div className="empty-state">
        <p>No user data available</p>
      </div>
    )
  }

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          <strong>{user.name}</strong> — {user.email}
        </li>
      ))}
    </ul>
  )
}
▶ 試してみよう

重要な注意点: fetch() はネットワークエラーが発生した場合にのみ例外をスローします。HTTP 4xx/5xx ステータスコードでは catch ブロックは実行されません。そのため、then 内で response.ok(または response.status)を手動で確認し、2xx 以外のレスポンスに対しては積極的にエラーをスローする必要があります。

(2) カスタムフックの抽出

すべてのページで3状態ロジックを繰り返し記述するのは明らかに非現実的です。トムは、この3状態ロジックをカスタムフックとして抽出しました。これにより、どのコンポーネントでもたった1行のコードでこのロジックを利用できるようになりました。

(2) ▶ サンプル:useFetch カスタムフック

JSX
import { useState, useEffect } from 'react'

// General Data Request Hook
function useFetch(fetchFn, deps = []) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  function execute() {
    setLoading(true)
    setError(null)

    fetchFn()
      .then(result => {
        setData(result)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }

  useEffect(() => {
    execute()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)

  return { data, loading, error, refetch: execute }
}

// ====== Usage ======
function UserList() {
  const { data: users, loading, error, refetch } = useFetch(
    () => fetch('https://jsonplaceholder.typicode.com/users')
            .then(r => { if (!r.ok) throw new Error('Request Failed'); return r.json() }),
    []
  )

  if (loading) return <p>Loading......</p>
  if (error) return <p>Error:{error} <button onClick={refetch}>Retry</button></p>

  return (
    <ul>
      {users?.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  )
}
▶ 試してみよう

フックの利点:

  1. コンポーネントのコードは、レンダリングロジックに焦点を当てるよう大幅に簡素化されました。
  2. 3値論理の一元的なメンテナンス;変更は1か所で行うだけで済む
  3. 再リクエストを手動でトリガーしやすくするため、refetch 関数がコンポーネントに対して公開されています

4. axiosの高度なラッパー

機能 fetch axios
インストール ブラウザに組み込み npm install axios
レスポンスの解析 マニュアル res.json() JSONへの自動変換
リクエスト/レスポンスのインターセプト 組み込み機能なし インターセプター interceptors
タイムアウト設定 AbortController timeout オプションと組み合わせて使用する必要があります
エラー処理 HTTP 4xx/5xx エラーは例外をスローしない HTTP エラーは自動的に例外としてスローされる
キャンセル依頼 AbortController CancelToken(旧) / AbortController(新)
TypeScript 手動での型アサーションが必要 ジェネリック axios.get<T>()

プロジェクトが拡大するにつれ、トムはトークンを手動で追加したり、401リダイレクトを処理したり、リクエストごとにタイムアウトを設定したりしなければならないことに気づきました。これは非常に面倒な作業でした。Axiosのインスタンス化とインターセプターの仕組みを使えば、これらの問題を一度にすべて解決できます。

BASH
npm install axios

(1) ▶ サンプル:axios インスタンスのラップ

JSX
import axios from 'axios'

// Get token(from  localStorage or  auth store)
function getToken() {
  return localStorage.getItem('auth_token')
}

// Create axios Examples
const api = axios.create({
  baseURL: '/api/v1',          // Basic Path
  timeout: 10000,              // Timeout (10s)
  headers: {
    'Content-Type': 'application/json',
  }
})

// ========== Request Interceptor ==========
api.interceptors.request.use(
  config => {
    // Auto-add Authorization header
    const token = getToken()
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }

    // Request Logs(Development Environment)
    if (process.env.NODE_ENV === 'development') {
      console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.params || '')
    }

    return config
  },
  error => {
    console.error('[API] Request configuration error:', error)
    return Promise.reject(error)
  }
)

// ========== Response Interceptors ==========
api.interceptors.response.use(
  // Successful Response:Return directly data Field(Remove the outer packaging)
  response => response.data,

  // Failure Response:Unified Error Handling
  error => {
    if (error.response) {
      // The server returned an error status code
      const { status, data } = error.response

      switch (status) {
        case 401:
          // Unauthorized → Clear token,Go to the Login Page
          localStorage.removeItem('auth_token')
          window.location.href = '/login'
          break
        case 403:
          console.warn('[API] Access Denied')
          break
        case 404:
          console.warn('[API] Resource does not exist')
          break
        case 500:
          console.error('[API] Internal Server Error')
          break
        default:
          console.error(`[API] HTTP ${status}:`, data?.message || 'Unknown error')
      }

      return Promise.reject(new Error(data?.message || `HTTP ${status}`))
    }

    if (error.code === 'ECONNABORTED') {
      // Request timed out
      return Promise.reject(new Error('Request timed out,Please check your internet connection.'))
    }

    // Network error (offline, DNS failure, etc.)
    return Promise.reject(new Error('Network Connection Error'))
  }
)

// ========== Export the packaged API Methods ==========
export const userApi = {
  getList: (params) => api.get('/users', { params }),
  getById: (id) => api.get(`/users/${id}`),
  create: (data) => api.post('/users', data),
  update: (id, data) => api.put(`/users/${id}`, data),
  delete: (id) => api.delete(`/users/${id}`),
}

export const productApi = {
  getList: (params) => api.get('/products', { params }),
  getById: (id) => api.get(`/products/${id}`),
}

// ========== Used in components ==========
function UserTable() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    userApi.getList({ page: 1, limit: 20 })
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])

  // ... Rendering Logic
}
▶ 試してみよう

インターセプターの力: リクエスト・インターセプターはトークンを自動的に挿入し、レスポンス・インターセプターは401リダイレクトを自動的に処理します。これにより、コンポーネントやAPI呼び出し元は、こうした横断的な課題について一切気にする必要がなくなります。


5. リクエストのキャンセルと競合状態

競合シナリオ 原因 解決策
検索入力のレースコンディション 高速な入力により複数のリクエストが発生し、古いレスポンスが新しいレスポンスを上書きする AbortController が古いリクエストをキャンセルする
ページ切り替え時のレースコンディション ページ切り替え後も古いリクエストへの応答が届き続ける useEffect のクリーンアップの中断
ボタンの連続クリック ユーザーが送信ボタンを複数回クリックする リクエスト中の「無効化」ボタンと「キャンセル」
タブ切り替え時のレースコンディション タブを素早く切り替えるとデータの不整合が生じる ignore フラグを使用して古い応答を無視する

これは、トムがこれまでに遭遇した中で最も見過ごされがちな落とし穴です。ユーザーが入力フィールドで素早く検索を行う場合(「a」→「ab」→「abc」→「abcd」と入力する)、ネットワーク速度にばらつきがあると、次のような現象が起こることがあります。「abcd」に対する応答が先に届き、その後で「a」に対する応答が届くのです(前のリクエストがキャンセルされなかったため)。その結果、ページには最新の検索結果である「abcd」ではなく、「a」の検索結果が表示されてしまいます。

この現象は「レースコンディション」と呼ばれます。解決策:新しいリクエストを開始する前に、前の未完了のリクエストをキャンセルしてください。

(1) ▶ サンプル:AbortController — リクエストの中止

JSX
import { useState, useEffect } from 'react'

function SearchUsers() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    if (!query.trim()) {
      setResults([])
      return
    }

    // Create AbortController
    const controller = new AbortController()
    const signal = controller.signal

    setLoading(true)

    fetch(`/api/users/search?q=${encodeURIComponent(query)}`, { signal })
      .then(res => res.json())
      .then(data => {
        setResults(data)
        setLoading(false)
      })
      .catch(err => {
        // Handle only non-canceled errors
        if (err.name !== 'AbortError') {
          console.error('Search Failed:', err)
          setLoading(false)
        }
      })

    // Cleanup Function:Component uninstallation or query Cancel the request when changes occur
    return () => {
      controller.abort()
    }
  }, [query])

  return (
    <div>
      <input
        placeholder="Search Users..."
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {loading && <p>Searching......</p>}
      <ul>
        {results.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}
▶ 試してみよう

主なメカニズム:

  1. query が変更されるたびに、useEffect のクリーンアップ関数が controller.abort() を呼び出し、前回のリクエストをキャンセルします。
  2. キャンセルされたリクエストはcatchブランチに入り、err.name !== 'AbortError'によって除外されます。これにより、誤ってエラーUIが起動されるのを防ぎます。
  3. 結局のところ、最後のリクエストに対する応答のみが setResults をトリガーすることになり、レースコンディションが完全に解消されます。

(2) ▶ サンプル:axios リクエストの中止

JSX
import { useState, useEffect } from 'react'
import axios from 'axios'

function SearchProducts() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    if (!query.trim()) {
      setResults([])
      return
    }

    // axios Cancel Token
    const source = axios.CancelToken.source()

    setLoading(true)

    axios.get('/api/products/search', {
      params: { q: query },
      cancelToken: source.token
    })
      .then(res => {
        setResults(res.data)
        setLoading(false)
      })
      .catch(err => {
        if (!axios.isCancel(err)) {
          console.error('Search Failed:', err)
          setLoading(false)
        }
        // Cancelled requests are not processed
      })

    return () => {
      source.cancel('The request has been canceled')  // Reason for Cancellation
    }
  }, [query])

  return (
    <div>
      <input
        placeholder="Search for Products..."
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {loading && <p>Searching......</p>}
      <ul>
        {results.map(p => (
          <li key={p.id}>{p.name} — ${p.price}</li>
        ))}
      </ul>
    </div>
  )
}
▶ 試してみよう

(3) ▶ サンプル:自動リトライ戦略

ネットワークリクエストは不安定なため、トムは失敗した際に自動的に再試行されるようにしたいと考えています(例えば、間隔を徐々に長くしながら2回再試行するなど)。Axiosには組み込みの再試行機能はありませんが、インターセプターを使用すれば簡単に実装できます:

JSX
// Retry Interceptor
function setupRetryInterceptor(axiosInstance, maxRetries = 2) {
  axiosInstance.interceptors.response.use(
    response => response,
    async error => {
      const config = error.config

      // Cases Where Retry Is Not Performed:Not configured、I've already tried again、It's not a network error
      if (!config || config._retryCount >= maxRetries) {
        return Promise.reject(error)
      }

      // Only in the event of a network error or 5xx Retry in Case of a Server Error
      const status = error.response?.status
      if (status && status < 500) {
        return Promise.reject(error)
      }

      config._retryCount = (config._retryCount || 0) + 1

      // Exponential backoff: 1st retry after 1s, 2nd retry after 2s
      const delay = config._retryCount * 1000
      await new Promise(r => setTimeout(r, delay))

      console.log(`[API] Retry ${config._retryCount}/${maxRetries}: ${config.url}`)
      return axiosInstance(config)
    }
  )
}

// When using
setupRetryInterceptor(api, 2)
▶ 試してみよう

指数関数的バックオフは、標準的な再試行戦略です。サーバーがすでに過負荷状態にある場合に、サーバーへの負荷が継続的にかからないよう、再試行のたびに待機時間を長くしていきます。


❓ よくある質問

Q fetch と axios、どちらを選ぶべきですか?
A fetch はブラウザに組み込まれている API であり、依存関係が不要で、単純なリクエストに適しています。ただし、HTTPエラーステータスコードの手動処理が必要であり(4xx/5xxエラーではcatchブロックがトリガーされません)、リクエストの進行状況の監視には対応していません。一方、axiosはJSONを自動的に解析し、リクエスト/レスポンスのインターセプターに対応しており、リクエストの中止も容易で、アップロードの進行状況の監視にも対応しています。推奨:小規模なプロジェクトではfetchを、大規模なプロジェクトではaxiosを使用することをお勧めします。
Q 3ステート管理では、なぜ単一のオブジェクトではなく、3つの別々の useState フックを使用するのですか?
A 3つの別々の useState フックを使用することで、コンポーネントはステートの特定の部分における変更を正確にサブスクライブできるようになります。単一の { data, loading, error } オブジェクトを使用すると、どのフィールドに変更があっても、そのオブジェクトを購読しているコンポーネントは再レンダリングされてしまいます。ただし、実際の開発においては、この2つのアプローチに大きな違いはないため、どちらが使い慣れているかによって選んでください。
Q AbortController がリクエストを中止した後も、サーバーは引き続きそのリクエストを処理しますか?
A はい。AbortControllerは、フロントエンドがレスポンスをリッスンするのを停止させるだけであり、サーバーは引き続きリクエストを受信して処理します(リクエストがサーバーに到達することを防ぐことはできません)。書き込み操作(POST/PUT/DELETE)の場合、バックエンドは冪等性を持つように設計されているか、あるいはフロントエンドが重複送信を防ぐための対策を講じる必要があります。
Q カスタムフック useFetch が複数のコンポーネントで呼び出された場合、それらのコンポーネントは状態を共有しますか?
A いいえ。コンポーネントが useFetch() を呼び出すたびに独立したスコープ(クロージャ)が作成されるため、それぞれの dataloadingerror の値は互いに干渉しません。コンポーネント間でリクエストデータを共有する必要がある場合(たとえば、2つのコンポーネントがともにユーザーリストを表示する場合など)、グローバルな状態管理とTanStack Queryが必要になります(次のレッスンをご覧ください)。
Q レスポンスインターセプター内で、ログインページへの401リダイレクトを一元的に処理する際、どのような点に注意すべきですか?
A 無限のリダイレクトループに陥らないよう注意が必要です。ログインページ自体が(トークンの有効性を確認するために)APIリクエストを行い、そのリクエストも401を返した場合、「リクエスト → 401 → リダイレクト → リクエスト → 401」という無限ループが発生してしまいます。解決策:インターセプター内で現在のページパスを確認し、ユーザーがすでに /login にいる場合は、リダイレクトを行わないようにします。

📖 まとめ


📝 練習問題

  1. ユーザーリストコンポーネントを作成する:fetchリクエストを使用してhttps://jsonplaceholder.typicode.com/usersからデータを取得し、4つのUI状態(読み込み中(スピナー)、エラー(エラーメッセージ+再試行ボタン)、データなし(データなし)、通常表示)を実装する。
  2. 上記の課題に基づき、3状態のロジックをカスタムフック useFetch に抽出してから、2つの異なるコンポーネントでそれを使用し、各状態が独立しているかどうかを検証してください。
  3. 検索コンポーネントを作成する:入力フィールドがユーザーの入力に基づいて検索を行うようにし(APIの遅延を500ミリ秒と想定)、AbortControllerを使用して以前の未完了のリクエストをキャンセルし、素早く入力してもデータが乱れないことを確認する。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%