HTTPリクエストとデータの取得
トムがユーザー管理ページでユーザー一覧を取得するためにAPIを呼び出した際、3つの問題が発生しました。第一に、ページを素早く切り替えると、前のリクエストからの応答が次のリクエストのデータを上書きしてしまう(レースコンディション)こと。第二に、ネットワークエラーが発生すると、エラーメッセージを表示することなくページが単に真っ白になってしまうこと。そして最後に、すべてのページで「読み込み中」「エラー」「データ表示」という3つの状態ロジックを繰り返し実装しなければならなかったことです。彼は、リクエストのライフサイクル全体を管理するためには、統一されたHTTPリクエストソリューションが必要であることに気づいた。
1. 学習内容
- Fetch API と Axios のどちらを選ぶか判断するための基準
- 「読み込み中」、「エラー」、「データ」の状態を管理するためのベストプラクティス
- AbortController:レースコンディションを防ぐためにリクエストを中止する
- Axiosのインスタンスのカプセル化とインターセプターの設定
- エラー処理と自動再試行戦略
2. 概念図
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つの状態変数を管理することです:
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 状態管理
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">⚠</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 カスタムフック
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>
)
}
フックの利点:
- コンポーネントのコードは、レンダリングロジックに焦点を当てるよう大幅に簡素化されました。
- 3値論理の一元的なメンテナンス;変更は1か所で行うだけで済む
- 再リクエストを手動でトリガーしやすくするため、
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のインスタンス化とインターセプターの仕組みを使えば、これらの問題を一度にすべて解決できます。
npm install axios
(1) ▶ サンプル:axios インスタンスのラップ
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 — リクエストの中止
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>
)
}
主なメカニズム:
queryが変更されるたびに、useEffect のクリーンアップ関数がcontroller.abort()を呼び出し、前回のリクエストをキャンセルします。- キャンセルされたリクエストはcatchブランチに入り、
err.name !== 'AbortError'によって除外されます。これにより、誤ってエラーUIが起動されるのを防ぎます。 - 結局のところ、最後のリクエストに対する応答のみが
setResultsをトリガーすることになり、レースコンディションが完全に解消されます。
(2) ▶ サンプル:axios リクエストの中止
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には組み込みの再試行機能はありませんが、インターセプターを使用すれば簡単に実装できます:
// 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)
指数関数的バックオフは、標準的な再試行戦略です。サーバーがすでに過負荷状態にある場合に、サーバーへの負荷が継続的にかからないよう、再試行のたびに待機時間を長くしていきます。
❓ よくある質問
catchブロックがトリガーされません)、リクエストの進行状況の監視には対応していません。一方、axiosはJSONを自動的に解析し、リクエスト/レスポンスのインターセプターに対応しており、リクエストの中止も容易で、アップロードの進行状況の監視にも対応しています。推奨:小規模なプロジェクトではfetchを、大規模なプロジェクトではaxiosを使用することをお勧めします。useState フックを使用するのですか?useState フックを使用することで、コンポーネントはステートの特定の部分における変更を正確にサブスクライブできるようになります。単一の { data, loading, error } オブジェクトを使用すると、どのフィールドに変更があっても、そのオブジェクトを購読しているコンポーネントは再レンダリングされてしまいます。ただし、実際の開発においては、この2つのアプローチに大きな違いはないため、どちらが使い慣れているかによって選んでください。useFetch が複数のコンポーネントで呼び出された場合、それらのコンポーネントは状態を共有しますか?useFetch() を呼び出すたびに独立したスコープ(クロージャ)が作成されるため、それぞれの data、loading、error の値は互いに干渉しません。コンポーネント間でリクエストデータを共有する必要がある場合(たとえば、2つのコンポーネントがともにユーザーリストを表示する場合など)、グローバルな状態管理とTanStack Queryが必要になります(次のレッスンをご覧ください)。/login にいる場合は、リダイレクトを行わないようにします。📖 まとめ
- 3つの状態(読み込み中/エラー/データ)の管理は、Reactのデータリクエストの基盤であり、これら4つのUI状態によってあらゆるシナリオを網羅しています。
- カスタムフックを使用して3状態ロジックを抽出することで、各コンポーネントでのコードの重複を回避し、一元的なメンテナンスを可能にする
- Axiosのインスタンスとインターセプターを使用して、トークンの自動挿入、401リダイレクトの自動処理、および統一されたエラー処理を実装する
- AbortController(fetch)およびCancelToken(axios)は、保留中のリクエストをキャンセルし、競合状態を完全に解消します
- 検索、ページ移動、タブの切り替えなど、頻繁に行われる操作については、キャンセル処理が必要です。
📝 練習問題
- ユーザーリストコンポーネントを作成する:fetchリクエストを使用して
https://jsonplaceholder.typicode.com/usersからデータを取得し、4つのUI状態(読み込み中(スピナー)、エラー(エラーメッセージ+再試行ボタン)、データなし(データなし)、通常表示)を実装する。 - 上記の課題に基づき、3状態のロジックをカスタムフック
useFetchに抽出してから、2つの異なるコンポーネントでそれを使用し、各状態が独立しているかどうかを検証してください。 - 検索コンポーネントを作成する:入力フィールドがユーザーの入力に基づいて検索を行うようにし(APIの遅延を500ミリ秒と想定)、AbortControllerを使用して以前の未完了のリクエストをキャンセルし、素早く入力してもデータが乱れないことを確認する。



