実際のWebアプリケーションでは、外部APIからデータを取得して表示することが一般的です。
この記事では、Fetch APIやAxiosを使ったデータ取得、ローディング状態の管理、エラーハンドリング、そして実践的なパターンを詳しく学んでいきます。
前提知識: useState と useEffect の基本を理解していることを前提にしています(#11〜#13 を先に読んでおくと理解がスムーズです)。
Contents
Fetch APIの基本
fetch() はブラウザに標準搭載されているAPIで、追加インストールなしで使えます。
Reactでは通常 useEffect の中でデータを取得します。
「コンポーネントが表示されたタイミングで1回だけAPIを叩く」という流れが基本パターンです。
なぜ3つのstateが必要なのか
APIを扱うには、次の3つの状態を常に意識します。
| state | 役割 |
|---|---|
data(今回は users) | 取得したデータそのもの |
loading | 通信中かどうか(ローディング表示の制御) |
error | エラーが起きたか、どんなエラーか |
この3点セットはほぼすべてのAPI処理で登場します。
基本的なデータ取得
JavaScript版:
import { useState, useEffect } from 'react'
function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => {
// fetch() はHTTPエラー(404, 500など)でも例外を投げないため、
// response.ok で明示的にチェックする必要がある
if (!response.ok) {
throw new Error('データの取得に失敗しました')
}
return response.json()
})
.then(data => {
setUsers(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, []) // [] を渡すことで「マウント時に1回だけ実行」になる
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
export default UserListTypeScript版:
TypeScript版では、APIレスポンスの型を interface で定義します。
useState にも型引数を渡してミスを防ぎます。
import { useState, useEffect } from 'react'
// ✅ APIレスポンスの型を定義しておく
interface User {
id: number
name: string
email: string
}
function UserList() {
const [users, setUsers] = useState<User[]>([]) // 型引数で配列の中身を指定
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then((response) => {
if (!response.ok) {
throw new Error('データの取得に失敗しました')
}
return response.json()
})
.then((data: User[]) => {
// ✅ 型アサーションでデータの型を明示
setUsers(data)
setLoading(false)
})
.catch((err) => {
// TypeScriptでは catch の err は unknown 型なので instanceof で確認する
setError(err instanceof Error ? err.message : '不明なエラー')
setLoading(false)
})
}, [])
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
export default UserListasync/awaitを使った書き方(推奨)
.then チェーンより読みやすく、エラーハンドリングも一か所にまとまるのでこちらが主流です。
ただし useEffect に渡す関数は直接 async にできないため、内側に async 関数を定義して呼び出すのがポイントです。
を定義して呼び出すのがポイントです。
jsx
// ❌ これはできない
useEffect(async () => { ... }, [])
// ✅ こうする
useEffect(() => {
const fetchData = async () => { ... }
fetchData()
}, [])JavaScript版:
このコードは、ページを開いてからデータが画面に表示されるまで、次の順番で動きます。
① コンポーネントが画面に表示される
↓
② useEffect が起動し、fetchUsers() を呼び出す
↓
③ loading: true になり「読み込み中...」が表示される
↓
④ fetch() でAPIにリクエストを送る(※ここで一時停止して待つ)
↓
┌─ 成功 ──────────────────────────┐
│ ⑤a response.ok を確認 │
│ ⑥a データをJSONに変換 │
│ ⑦a users に保存 │
└─────────────────────────────────┘
┌─ 失敗 ──────────────────────────┐
│ ⑤b catch に飛ぶ │
│ ⑥b error にメッセージを保存 │
└────────────────────────────────┘
↓(成功・失敗どちらでも)
⑧ finally: loading: false になる
↓
⑨ error があればエラー表示、なければユーザー一覧を表示import { useState, useEffect } from 'react'
function UserList() {
// ① コンポーネントが表示されたとき、3つのstateが初期値でセットされる
const [users, setUsers] = useState([]) // 取得したデータの置き場所
const [loading, setLoading] = useState(true) // 最初からtrue(通信前もローディング扱い)
const [error, setError] = useState(null)
// ② 画面に表示された直後に1回だけ実行される([] が「初回のみ」の指示)
useEffect(() => {
const fetchUsers = async () => {
try {
// ③ ローディング開始
setLoading(true)
// ④ APIにリクエストを送る。await で結果が返るまで待機する
const response = await fetch('https://jsonplaceholder.typicode.com/users')
// ⑤a 通信は成功したが、サーバーがエラーを返した場合(404・500など)はここで弾く
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// ⑥a レスポンスのJSON文字列をJavaScriptのオブジェクトに変換する
const data = await response.json()
// ⑦a 取得したデータをstateに保存 → 画面の再描画が走る
setUsers(data)
setError(null)
} catch (err) {
// ⑤b〜⑥b 通信エラー or response.ok チェックで throw された場合にここに来る
setError(err.message)
setUsers([])
} finally {
// ⑧ 成功・失敗どちらの場合も必ずローディングを解除する
setLoading(false)
}
}
fetchUsers()
}, [])
// ⑨ stateに応じて表示内容を切り替える
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}TypeScript版:
import { useState, useEffect } from 'react'
// ✅ APIレスポンスの型を定義しておく
interface User {
id: number
name: string
email: string
}
function UserList() {
const [users, setUsers] = useState<User[]>([]) // 型引数で配列の中身を指定
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
// async関数には戻り値の型 Promise<void> を明示する
const fetchUsers = async (): Promise<void> => {
try {
setLoading(true)
const response = await fetch(
'https://jsonplaceholder.typicode.com/users'
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: User[] = await response.json()
setUsers(data)
setError(null) // null は OK(string | null の null)
} catch (err) {
setError(err instanceof Error ? err.message : '不明なエラー')
// ↑ err.message は string → string | null に含まれるので OK
setUsers([])
} finally {
setLoading(false)
}
}
fetchUsers()
}, [])
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
export default UserListAxiosを使ったデータ取得
Fetch API vs Axios — どちらを使うべきか
| Fetch API | Axios | |
|---|---|---|
| インストール | 不要(ブラウザ標準) | npm install axios が必要 |
| JSONの自動変換 | response.json() を手動で呼ぶ必要あり | 自動(response.data で取得) |
| HTTPエラーの検知 | 手動で response.ok をチェック | 自動で例外を投げてくれる |
| インターセプター | 自前で実装が必要 | リクエスト/レスポンスの一元管理が可能 |
| タイムアウト設定 | やや複雑 | 簡単 |
個人プロジェクトや小規模ならFetch APIで十分。
チーム開発や認証ヘッダーなどの共通処理が多い場合はAxiosが便利です。
Axiosのインストール
npm install axios基本的な使い方
JavaScript版:
import { useState, useEffect } from 'react'
import axios from 'axios'
function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true)
// Fetch APIと違い、response.ok のチェックが不要
// エラー時は自動的に catch に飛ぶ
const response = await axios.get('https://jsonplaceholder.typicode.com/users')
setUsers(response.data) // response.json() も不要
setError(null)
} catch (err) {
setError(err.message)
setUsers([])
} finally {
setLoading(false)
}
}
fetchUsers()
}, [])
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}TypeScript版:
import { useState, useEffect } from 'react'
import axios from 'axios'
interface User {
id: number
name: string
email: string
}
function UserList() {
const [users, setUsers] = useState<User[]>([]) // ← データ型を指定する
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true)
// Fetch APIと違い、response.ok のチェックが不要
// エラー時は自動的に catch に飛ぶ
const response = await axios.get<User[]>(
'https://jsonplaceholder.typicode.com/users'
)
setUsers(response.data) // TypeScriptが型を認識してくれる
setError(null)
} catch (err) {
// ⚠️ ポイント:catch の err は TypeScript 4.0 以降 unknown 型になっている
//
// ❌ err.message と直接アクセスすると「unknown 型にはプロパティがない」エラーになる
// catch (err) { setError(err.message) }
//
// ✅ instanceof Error で「Error オブジェクトかどうか」を確認してからアクセスする
// これで TypeScript に「ここでは err は Error 型だ」と伝えられる
setError(err instanceof Error ? err.message : '不明なエラー')
setUsers([])
} finally {
setLoading(false)
}
}
fetchUsers()
}, [])
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
export default UserList
インターセプター
インターセプターとは、すべてのリクエスト・レスポンスを「通過する共通の関門」です。
認証トークンの付与やエラーの一括処理など、毎回書かなくていい共通処理をまとめるのに使います。
インターセプターを使わないと、トークン付与やエラー処理をコンポーネントごとに毎回書く必要があります。
リクエストからレスポンスまでの往復にあたって、前処理や後処理のタイミングとイメージすると良いです。
import axios from 'axios'
// axios.create() でベースURLなどを共通設定したインスタンスを作る
const apiClient = axios.create({
baseURL: 'https://api.example.com'
})
// ── リクエストインターセプター ──────────────────
// すべてのリクエストが飛ぶ直前に実行される
apiClient.interceptors.request.use(
(config) => {
// 例:認証トークンをすべてのリクエストに自動付与する
// これがないと各コンポーネントで毎回 headers を書く必要がある
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// ── レスポンスインターセプター ─────────────────
// すべてのレスポンスを受け取った直後に実行される
apiClient.interceptors.response.use(
(response) => response, // 成功時はそのまま返す
(error) => {
// 例:401(未認証)ならログイン画面にリダイレクト
// これがないと各コンポーネントで毎回 401 チェックを書く必要がある
if (error.response?.status === 401) {
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default apiClient作成した apiClient を axios の代わりに使うだけで、すべてのリクエストに共通処理が自動で適用されます。
// axios ではなく apiClient を使う
const response = await apiClient.get<User[]>('/users')タイムアウト設定
APIが応答を返さない場合、何秒待ったら諦めるかを指定する設定です。
指定しないと応答が返るまで永遠に待ち続けることになります。
import axios from 'axios'
// ── インスタンス作成時に設定する(推奨)──────────
const apiClient = axios.create({
baseURL: 'https://api.example.com',
timeout: 5000 // 5秒(ミリ秒単位)。5秒以内に応答がなければエラーにする
})
// ── リクエストごとに個別指定もできる ─────────────
const response = await axios.get('/users', {
timeout: 3000 // このリクエストだけ3秒
})タイムアウトしたときは err.code が 'ECONNABORTED' になるので、専用のエラーメッセージを出せます。
try {
const response = await apiClient.get<User[]>('/users')
} catch (err) {
if (axios.isAxiosError(err) && err.code === 'ECONNABORTED') {
// タイムアウト専用のメッセージ
setError('通信がタイムアウトしました。再度お試しください。')
} else {
setError(err instanceof Error ? err.message : '不明なエラー')
}
}Fetch API でも AbortController を使えば同じことはできますが、コードが増えます。
// Fetch API でのタイムアウト(比較用)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch('/users', { signal: controller.signal })
} finally {
clearTimeout(timeoutId) // タイマーを必ずクリアする
}Axios の timeout: 5000 一行と比べるとコード量の差は一目瞭然で、これが比較表に書いた「タイムアウト設定が簡単」の意味です。
ローディング状態の管理
なぜローディング表示が重要なのか
APIの応答には数百ms〜数秒かかることがあります。
その間に「何も表示されない」状態が続くと、ユーザーはアプリが壊れたと感じてしまいます。
ローディング表示はUX(ユーザー体験)に直結する重要な要素です。
スケルトンローディング
単純な「読み込み中…」テキストより、実際のレイアウトに似たプレースホルダー(スケルトン)を表示する方が洗練されています。
CSS:
/* App.cssに追加 */
/* カード本体のサイズと余白を定義 */
.user-card {
padding: 16px;
border: 1px solid #eee;
border-radius: 8px;
background: #fff;
min-height: 80px; /* ← これがないとスケルトンの高さがゼロになる */
}
/* グリッドレイアウト */
.user-grid {
display: grid;
grid-template-columns: repeat(3, minmax(200px, 1fr));
gap: 16px;
padding: 16px;
}
.skeleton {
animation: pulse 1.5s infinite;
}
.skeleton-title {
height: 20px;
background: #ddd;
margin-bottom: 10px;
border-radius: 4px;
}
.skeleton-text {
height: 14px;
background: #ddd;
border-radius: 4px;
}
/* 点滅アニメーションで「読み込み中」感を演出 */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
JavaScript版:
//
import { useState, useEffect } from 'react'
// 実際のカードコンポーネント
function UserCard({ user }) {
return (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)
}
// ローディング中に表示するスケルトン(骨格)コンポーネント
function SkeletonCard() {
return (
<div className="user-card skeleton">
<div className="skeleton-title"></div>
<div className="skeleton-text"></div>
</div>
)
}
function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// 開発確認用:n ミリ秒待つユーティリティ関数
// const sleep = (ms: number): Promise<void> =>
// new Promise((resolve) => setTimeout(resolve, ms))
const fetchUsers = async () => {
// await sleep(5000) // 5秒待つ
const response = await fetch('https://jsonplaceholder.typicode.com/users')
const data = await response.json()
setUsers(data)
setLoading(false)
}
fetchUsers()
}, [])
return (
<div className="user-grid">
{loading
? Array(6).fill(0).map((_, i) => <SkeletonCard key={i} />) // 6枚のスケルトンを表示
: users.map(user => <UserCard key={user.id} user={user} />)
}
</div>
)
}TypeScript版:
// types/index.ts 末尾に追加
export interface User {
id: number
name: string
email: string
}
// components/SkeletonCard.tsx
// ローディング中に表示するスケルトン(骨格)コンポーネント
import { type JSX } from 'react'
function SkeletonCard(): JSX.Element {
return (
<div className="user-card skeleton">
<div className="skeleton-title"></div>
<div className="skeleton-text"></div>
</div>
)
}
export default SkeletonCard// components/UserCard.tsx
import { type JSX } from 'react'
import { type User } from '../types'
interface UserCardProps {
user: User
}
// 実際のカードコンポーネント
function UserCard({ user }: UserCardProps): JSX.Element {
return (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)
}
export default UserCard // pages/UserList.tsx
import { useState, useEffect } from 'react'
import UserCard from '../components/ UserCard'
import SkeletonCard from '../components/SkeletonCard'
import { type User } from '../types'
function UserList() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState<boolean>(true)
useEffect(() => {
// 開発確認用:n ミリ秒待つユーティリティ関数
// const sleep = (ms: number): Promise<void> =>
// new Promise((resolve) => setTimeout(resolve, ms))
const fetchUsers = async (): Promise<void> => {
// await sleep(5000) // 5秒待つ
const response = await fetch('https://jsonplaceholder.typicode.com/users')
const data = await response.json()
setUsers(data)
setLoading(false)
}
fetchUsers()
}, [])
return (
<div className="user-grid">
{loading
? Array(6)
.fill(0)
.map((_, i) => <SkeletonCard key={i} />) // 6枚のスケルトンを表示
: users.map((user) => <UserCard key={user.id} user={user} />)}
</div>
)
}
export default UserList// App.tsx
import { type JSX } from 'react'
import UserList from './pages/UserList'
import './App.css'
function App(): JSX.Element {
return <UserList />
}
export default Appスケルトン実現のポイント
スケルトンローディングは以下の4つの組み合わせで成り立っています。
① loading の true/false で表示を丸ごと切り替える
スケルトンと実データは同じ場所に配置されていて、loading の値だけで出し分けています。
{loading
? Array(6).fill(0).map((_, i) => <SkeletonCard key={i} />) // loading=true → スケルトン
: users.map(user => <UserCard key={user.id} user={user} />) // loading=false → 実データ
}② Array(6).fill(0).map() でスケルトンを量産する
実データの件数はAPIを叩くまでわかりません。
そこで Array(6) で「とりあえず6枚」と決め打ちしてスケルトンを並べます。
Array(6).fill(0).map((_, i) => <SkeletonCard key={i} />)
// ↑ 長さ6の配列 ↑ 0で埋める ↑ 値は使わないので _ 、i は key に使う③ CSSアニメーションで「読み込み中」感を演出する
スケルトンの見た目自体はただのグレーのボックスです。animation: pulse を当てることで点滅し、「何かが読み込まれている」ことをユーザーに伝えます。
.skeleton {
animation: pulse 1.5s infinite; /* 1.5秒周期で無限に繰り返す */
}
@keyframes pulse {
0%, 100% { opacity: 1; } /* 完全に表示 */
50% { opacity: 0.5; } /* 半透明 */
}エラーハンドリング
エラーを console.error だけで処理すると、ユーザーは何が起きたかわかりません。
「再試行ボタン付きのエラー表示」を実装することで、ユーザーが自力で回復できる余地を作ります。
詳細なエラー表示
JavaScript版:
UserListコンポーネントのファイルにまとめて書いています。
エラーのコンポーネントを分割してみてるなど試してください。
import { useState, useEffect } from 'react'
// エラー表示専用コンポーネント(再試行ボタン付き)
function ErrorMessage({ error, onRetry }) {
return (
<div className="error-container">
<h2>エラーが発生しました</h2>
<p>{error}</p>
<button onClick={onRetry}>再試行</button>
</div>
)
}
function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// useEffect の外に定義することで、再試行ボタンからも呼べる
const fetchUsers = async () => {
try {
setLoading(true)
setError(null) // 再試行時に前のエラーをリセット
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setUsers(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUsers()
}, [])
if (loading) return <p>読み込み中...</p>
if (error) return <ErrorMessage error={error} onRetry={fetchUsers} />
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}TypeScript版:
import { useState, useEffect, type JSX } from 'react'
interface ErrorMessageProps {
error: string
onRetry: () => void// 引数なし・戻り値なしの関数
}
function ErrorMessage({ error, onRetry }: ErrorMessageProps): JSX.Element {
return (
<div className="error-container">
<h2>エラーが発生しました</h2>
<p>{error}</p>
<button onClick={onRetry}>再試行</button>
</div>
)
}
interface User {
id: number
name: string
email: string
}
function UserList(): JSX.Element {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const fetchUsers = async (): Promise<void> => {
try {
setLoading(true)
setError(null)
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: User[] = await response.json()
setUsers(data)
} catch (err) {
setError(err instanceof Error ? err.message : '不明なエラー')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUsers()
}, [])
if (loading) return <p>読み込み中...</p>
if (error) return <ErrorMessage error={error} onRetry={fetchUsers} />
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}データの更新(POST、PUT、DELETE)
HTTPメソッドの使い分け
| メソッド | 用途 | 例 |
|---|---|---|
GET | データ取得 | ユーザー一覧を取得 |
POST | データ新規作成 | 新しいユーザーを登録 |
PUT | データ全体を更新 | ユーザー情報をまるごと書き換え |
PATCH | データの一部を更新 | メールアドレスだけ変更 |
DELETE | データ削除 | ユーザーを削除 |
POSTリクエスト(データ作成)
headersにJSON を送ることをサーバーに伝える事とオブジェクトをJSON文字列に変換部分がポイントとなります。
JavaScript版:
import { useState } from 'react'
function CreateUser() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
try {
setLoading(true)
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // JSON を送ることをサーバーに伝える
},
body: JSON.stringify({ name, email }) // オブジェクトをJSON文字列に変換
})
const data = await response.json()
setResult(`ユーザーが作成されました: ID ${data.id}`)
setName('')
setEmail('')
} catch (err) {
setResult(`エラー: ${err.message}`)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前"
required
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メール"
required
/>
<button type="submit" disabled={loading}>
{loading ? '作成中...' : '作成'}
</button>
{result && <p>{result}</p>}
</form>
)
}
export default CreateUserTypeScript版:
import { useState } from 'react'
// レスポンスの型を定義
interface CreateUserResponse {
id: number
name: string
email: string
}
function CreateUser(): JSX.Element {
const [name, setName] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [result, setResult] = useState<string | null>(null)
// フォームのsubmitイベントに型を付ける
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
try {
setLoading(true)
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, email })
})
const data: CreateUserResponse = await response.json()
setResult(`ユーザーが作成されました: ID ${data.id}`)
setName('')
setEmail('')
} catch (err) {
setResult(`エラー: ${err instanceof Error ? err.message : '不明なエラー'}`)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前"
required
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メール"
required
/>
<button type="submit" disabled={loading}>
{loading ? '作成中...' : '作成'}
</button>
{result && <p>{result}</p>}
</form>
)
}
export default CreateUserPUTリクエスト(データ更新)
パスパラメータで更新対象を指定する事とメソッドがPUTを指定しているところがポイントです。
JavaScript版:
import { useState } from 'react'
function UpdateUser({ userId }) {
const [name, setName] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const handleUpdate = async () => {
try {
setLoading(true)
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
}
)
const data = await response.json()
console.log('更新完了:', data)
} catch (err) {
console.error('エラー:', err)
} finally {
setLoading(false)
}
}
return (
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="新しい名前"
/>
<button onClick={handleUpdate} disabled={loading}>
{loading ? '更新中...' : '更新'}
</button>
</div>
)
}
DELETEリクエスト(データ削除)
削除は元に戻せないため、確認ダイアログを挟むのがUXの基本です。
JavaScript版:
import { useState } from 'react'
function DeleteUser({ userId, onDelete }) {
const [loading, setLoading] = useState(false)
const handleDelete = async () => {
// 誤操作を防ぐための確認ダイアログ
if (!window.confirm('本当に削除しますか?')) return
try {
setLoading(true)
await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ method: 'DELETE' }
)
onDelete(userId) // 親コンポーネントにIDを渡して一覧からも削除
} catch (err) {
console.error('エラー:', err)
} finally {
setLoading(false)
}
}
return (
<button onClick={handleDelete} disabled={loading}>
{loading ? '削除中...' : '削除'}
</button>
)
}
export default DeleteUserTypeScript版:
import { useState, type JSX } from 'react'
interface DeleteUserProps {
userId: number
onDelete: (userId: number) => void
}
function DeleteUser({ userId, onDelete }: DeleteUserProps): JSX.Element {
const [loading, setLoading] = useState<boolean>(false)
const handleDelete = async (): Promise<void> => {
if (!window.confirm('本当に削除しますか?')) {
return
}
try {
setLoading(true)
await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
method: 'DELETE'
})
onDelete(userId)
} catch (err) {
console.error('エラー:', err)
} finally {
setLoading(false)
}
}
return (
<button onClick={handleDelete} disabled={loading}>
{loading ? '削除中...' : '削除'}
</button>
)
}
export default DeleteUserページネーション
大量のデータを一度に取得するのはパフォーマンス的に問題があります。
ページネーションは「何ページ目のデータを取得するか」をAPIに渡す仕組みです。
currentPage が変わるたびにAPIを叩きなおすため、useEffect の依存配列に currentPage を入れます。
JavaScript版:
import { useState, useEffect } from 'react'
function PaginatedList() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages] = useState(10)
const itemsPerPage = 10
useEffect(() => {
const fetchPosts = async () => {
setLoading(true)
try {
// クエリパラメータでページ番号と件数を指定
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${currentPage}&_limit=${itemsPerPage}`
)
const data = await response.json()
setPosts(data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [currentPage]) // ← currentPage が変わるたびに再実行
if (loading) return <p>読み込み中...</p>
return (
<div>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<div className="pagination">
<button
onClick={() => setCurrentPage(prev => prev - 1)}
disabled={currentPage === 1} // 1ページ目では「前へ」を無効化
>
前へ
</button>
<span>ページ {currentPage} / {totalPages}</span>
<button
onClick={() => setCurrentPage(prev => prev + 1)}
disabled={currentPage === totalPages} // 最終ページでは「次へ」を無効化
>
次へ
</button>
</div>
</div>
)
}TypeScript版:
import { useState, useEffect, type JSX } from 'react'
interface Post {
id: number
title: string
body: string
}
function PaginatedList(): JSX.Element {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [currentPage, setCurrentPage] = useState<number>(1)
const [totalPages] = useState<number>(10)
const itemsPerPage = 10
useEffect(() => {
const fetchPosts = async (): Promise<void> => {
setLoading(true)
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${currentPage}&_limit=${itemsPerPage}`
)
const data: Post[] = await response.json()
setPosts(data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [currentPage])
if (loading) return <p>読み込み中...</p>
return (
<div>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<div className="pagination">
<button
onClick={() => setCurrentPage(prev => prev - 1)}
disabled={currentPage === 1}
>
前へ
</button>
<span>ページ {currentPage} / {totalPages}</span>
<button
onClick={() => setCurrentPage(prev => prev + 1)}
disabled={currentPage === totalPages}
>
次へ
</button>
</div>
</div>
)
}検索・フィルタリング機能
デバウンスとは
検索ボックスに1文字入力するたびにAPIを叩くのは非効率です。
デバウンスは「入力が止まってから一定時間後に処理を実行する」テクニックで、API呼び出し回数を大幅に削減できます。
入力: "r" → "re" → "rea" → "reac" → "react"
↑ 500ms後にここでAPIを叩くまずカスタムフックを作っておきます:
// hooks/useDebounce.js
import { useState, useEffect } from 'react'
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// 新しい入力が来たらタイマーをリセット
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
export default useDebounceTS版
// hooks/useDebounce.ts
import { useState, useEffect } from 'react'
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// 新しい入力が来たらタイマーをリセット
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
export default useDebounceJavaScript版:
import { useState, useEffect } from 'react'
import useDebounce from '../hooks/useDebounce'
function SearchableList() {
const [posts, setPosts] = useState([])
const [searchTerm, setSearchTerm] = useState('')
const [loading, setLoading] = useState(false)
// 500ms入力が止まったら debouncedSearchTerm が更新される
const debouncedSearchTerm = useDebounce(searchTerm, 500)
useEffect(() => {
const searchPosts = async () => {
if (!debouncedSearchTerm) {
setPosts([])
return
}
setLoading(true)
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?q=${debouncedSearchTerm}`
)
const data = await response.json()
setPosts(data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
searchPosts()
}, [debouncedSearchTerm]) // debouncedSearchTerm が変わったときだけ実行
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="検索..."
/>
{loading && <p>検索中...</p>}
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
{!loading && posts.length === 0 && searchTerm && (
<p>「{searchTerm}」に一致する結果がありません</p>
)}
</div>
)
}TypeScript版:
// pages/SearchableList.tsx
import { useState, useEffect } from 'react'
import useDebounce from '../hooks/useDebounce'
//Postはtypes/index.tsに宣言しても良い
interface Post {
id: number
title: string
}
function SearchableList() {
const [posts, setPosts] = useState<Post[]>([])
const [searchTerm, setSearchTerm] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
// 500ms入力が止まったら debouncedSearchTerm が更新される
const debouncedSearchTerm = useDebounce(searchTerm, 500)
useEffect(() => {
const searchPosts = async () => {
if (!debouncedSearchTerm) {
setPosts([])
return
}
setLoading(true)
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?q=${debouncedSearchTerm}`
)
const data = await response.json()
setPosts(data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
searchPosts()
}, [debouncedSearchTerm]) // debouncedSearchTerm が変わったときだけ実行
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="検索..."
/>
{loading && <p>検索中...</p>}
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
{!loading && posts.length === 0 && searchTerm && (
<p>「{searchTerm}」に一致する結果がありません</p>
)}
</div>
)
}
export default SearchableList実践:完全なCRUDアプリケーション
ここまで学んだ内容を組み合わせた、TodoアプリのCRUD実装です。
JavaScript版:
import { useState, useEffect } from 'react'
function TodoApp() {
const [todos, setTodos] = useState([])
const [loading, setLoading] = useState(true)
const [newTodo, setNewTodo] = useState('')
const [editingId, setEditingId] = useState(null)
const [editText, setEditText] = useState('')
// ── Read(取得)──────────────────────────────
useEffect(() => {
const fetchTodos = async () => {
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos?_limit=5'
)
const data = await response.json()
setTodos(data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
fetchTodos()
}, [])
// ── Create(作成)────────────────────────────
const handleCreate = async (e) => {
e.preventDefault()
if (!newTodo.trim()) return
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTodo, completed: false })
})
const data = await response.json()
setTodos([data, ...todos]) // 先頭に追加
setNewTodo('')
} catch (err) {
console.error(err)
}
}
// ── Update(更新)────────────────────────────
const handleUpdate = async (id) => {
try {
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: editText })
})
// APIの更新が成功したらローカルのstateも更新(再fetchしない)
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, title: editText } : todo
))
setEditingId(null)
setEditText('')
} catch (err) {
console.error(err)
}
}
// ── Delete(削除)────────────────────────────
const handleDelete = async (id) => {
try {
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'DELETE'
})
setTodos(todos.filter(todo => todo.id !== id))
} catch (err) {
console.error(err)
}
}
// ── Toggle(完了/未完了の切り替え)──────────
const handleToggle = async (id) => {
const todo = todos.find(t => t.id === id)
try {
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', // 一部更新なのでPATCH
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed })
})
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
} catch (err) {
console.error(err)
}
}
if (loading) return <p>読み込み中...</p>
return (
<div className="todo-app">
<h1>Todoアプリ</h1>
{/* 新規作成フォーム */}
<form onSubmit={handleCreate}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="新しいTodoを入力"
/>
<button type="submit">追加</button>
</form>
{/* Todoリスト */}
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
{editingId === todo.id ? (
// 編集モード
<div>
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
/>
<button onClick={() => handleUpdate(todo.id)}>保存</button>
<button onClick={() => setEditingId(null)}>キャンセル</button>
</div>
) : (
// 表示モード
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span>{todo.title}</span>
<button onClick={() => {
setEditingId(todo.id)
setEditText(todo.title)
}}>編集</button>
<button onClick={() => handleDelete(todo.id)}>削除</button>
</div>
)}
</li>
))}
</ul>
</div>
)
}
export default TodoAppTypeScript版:
// src/pages/TodoApp.tsx
import { useState, useEffect, type JSX } from 'react'
interface Todo {
id: number
title: string
completed: boolean
}
function TodoApp() : JSX.Element{
const [todos, setTodos] = useState<Todo[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [newTodo, setNewTodo] = useState<string>('')
const [editingId, setEditingId] = useState<number | null>(null)
const [editText, setEditText] = useState<string>('')
// ── Read(取得)──────────────────────────────
useEffect(() => {
const fetchTodos = async () => {
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos?_limit=5'
)
const data = await response.json()
setTodos(data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
fetchTodos()
}, [])
// ── Create(作成)────────────────────────────
const handleCreate = async (e: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
if (!newTodo.trim()) return
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTodo, completed: false })
})
const data = await response.json()
setTodos([data, ...todos]) // 先頭に追加
setNewTodo('')
} catch (err) {
console.error(err)
}
}
// ── Update(更新)────────────────────────────
const handleUpdate = async (id: number): Promise<void> => {
try {
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: editText })
})
// APIの更新が成功したらローカルのstateも更新(再fetchしない)
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, title: editText } : todo
))
setEditingId(null)
setEditText('')
} catch (err) {
console.error(err)
}
}
// ── Delete(削除)────────────────────────────
const handleDelete = async (id: number): Promise<void> => {
try {
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'DELETE'
})
setTodos(todos.filter(todo => todo.id !== id))
} catch (err) {
console.error(err)
}
}
// ── Toggle(完了/未完了の切り替え)──────────
const handleToggle = async (id: number): Promise<void> => {
const todo = todos.find(t => t.id === id)
if (!todo) return
try {
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', // 一部更新なのでPATCH
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed })
})
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
} catch (err) {
console.error(err)
}
}
if (loading) return <p>読み込み中...</p>
return (
<div className="todo-app">
<h1>Todoアプリ</h1>
{/* 新規作成フォーム */}
<form onSubmit={handleCreate}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="新しいTodoを入力"
/>
<button type="submit">追加</button>
</form>
{/* Todoリスト */}
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
{editingId === todo.id ? (
// 編集モード
<div>
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
/>
<button onClick={() => handleUpdate(todo.id)}>保存</button>
<button onClick={() => setEditingId(null)}>キャンセル</button>
</div>
) : (
// 表示モード
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span>{todo.title}</span>
<button onClick={() => {
setEditingId(todo.id)
setEditText(todo.title)
}}>編集</button>
<button onClick={() => handleDelete(todo.id)}>削除</button>
</div>
)}
</li>
))}
</ul>
</div>
)
}
export default TodoAppベストプラクティス
1. カスタムフックに抽出
同じデータ取得ロジックを複数コンポーネントで使い回したいとき、カスタムフックにまとめるとコードの重複が減り、テストもしやすくなります。
// hooks/useFetch.js
import { useState, useEffect } from 'react'
function useFetch(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
const response = await fetch(url)
if (!response.ok) throw new Error('Fetch failed')
const json = await response.json()
setData(json)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchData()
}, [url])
return { data, loading, error }
}
export default useFetch使い方がシンプルになります:
function UserList() {
const { data: users, loading, error } = useFetch('https://api.example.com/users')
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
return <ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}2. AbortControllerでリクエストをキャンセル
コンポーネントがアンマウント(画面から消える)されたあとに、進行中のAPIレスポンスが返ってきてstateを更新しようとすると、メモリリークや警告の原因になります。
ブラウザの標準機能(Web API)のAbortController を使えば、クリーンアップ時にリクエストを安全にキャンセルできます。
useEffect(() => {
const controller = new AbortController()
const fetchData = async () => {
try {
const response = await fetch(url, {
signal: controller.signal // コントローラーと紐づける
})
const data = await response.json()
setData(data)
} catch (err) {
if (err.name === 'AbortError') {
// キャンセルによるエラーは無視する
console.log('Fetch aborted')
} else {
setError(err.message)
}
}
}
fetchData()
// useEffect のクリーンアップ:コンポーネントがアンマウントされたら abort
return () => controller.abort()
}, [url])3. 環境変数でAPIエンドポイントを管理
URLをコンポーネントにハードコードすると、本番環境への切り替えのたびに書き換えが必要になります。環境変数を使いましょう。
# .env(プロジェクトルートに作成)
VITE_API_URL=https://api.example.com
// APIクライアント
const API_URL = import.meta.env.VITE_API_URL
export const fetchUsers = async () => {
const response = await fetch(`${API_URL}/users`)
return response.json()
}これで VITE_API_URL を変えるだけで、開発・ステージング・本番のAPIを切り替えられます。
4. TanStack Query(React Query)の検討
より大規模なアプリになってきたら、データフェッチング専用ライブラリの TanStack Query(旧 React Query) の導入も検討してみてください。
キャッシュ管理・再フェッチ・バックグラウンド更新などを自動でやってくれるため、今回のようなボイラープレート(何度も繰り返し書く必要があるが、ほとんど中身が変わらない定型コード)を大幅に削減できます。
補足:TypeScript の型推論について
この記事のサンプルコードでは、学習目的のため useState の型引数をすべて明示的に書いています。TypeScript に慣れてきたら、型推論を活用してコードを簡潔にできます。
型推論が使える場合
初期値から型が一意に決まる場合、型引数を省略できます。
tsx
// 型引数あり(この記事のスタイル)
const [loading, setLoading] = useState<boolean>(false)
const [count, setCount] = useState<number>(0)
const [name, setName] = useState<string>('')
// 型引数なし(型推論に任せる)
const [loading, setLoading] = useState(false) // boolean と推論される
const [count, setCount] = useState(0) // number と推論される
const [name, setName] = useState('') // string と推論される型引数が必須な場合
初期値だけでは型が確定しない場合は、必ず型引数を書く必要があります。
tsx
// ❌ never[] と推論されて後から User[] を渡せなくなる
const [users, setUsers] = useState([])
// ✅ 型引数で「User の配列」と明示する(必須)
const [users, setUsers] = useState<User[]>([])
// ❌ null 型のみと推論されて後から string を渡せなくなる
const [error, setError] = useState(null)
// ✅ string | null と明示する(必須)
const [error, setError] = useState<string | null>(null)| 初期値 | 推論される型 | 型引数 |
|---|---|---|
false / true | boolean | 省略可 |
0 / 1 など | number | 省略可 |
'' / 'hello' など | string | 省略可 |
[] | never[] | 必須 |
null | null | 必須 |
まとめ
この記事では、ReactでAPIを扱う全体像を学びました。
基本の流れ
useEffect でトリガー
→ fetch / axios でリクエスト
→ loading: true でローディング表示
→ 成功 → data に格納
→ 失敗 → error に格納
→ loading: false でローディング解除学んだこと
- Fetch API vs Axios:小規模ならFetch、共通処理が多いならAxios
- 3つの状態管理:
data / loading / errorの3点セットが基本 - 非同期処理:
async/await+try/catch/finallyで統一 - HTTPメソッド:GET / POST / PUT / PATCH / DELETE の使い分け
- UX改善:スケルトンローディング・エラー時の再試行ボタン
- パフォーマンス:デバウンスでAPI呼び出し回数を削減
- ベストプラクティス:カスタムフック・AbortController・環境変数
次のステップ:
次回は、実践的なTodoアプリケーションの完成版を作ります。
ここまで学んだ知識を総動員して、本格的なアプリケーションを構築していきましょう!
API連携はモダンなWebアプリケーションの基本です。様々なAPIと連携しながら、実践的なスキルを身につけていきましょう!


























