デザインについての学習メモブログ

React入門 #14 – APIからデータを取得して表示する

記事内に広告が含まれています。

React入門 #14 – APIからデータを取得して表示する

実際のWebアプリケーションでは、外部APIからデータを取得して表示することが一般的です。

この記事では、Fetch APIやAxiosを使ったデータ取得、ローディング状態の管理、エラーハンドリング、そして実践的なパターンを詳しく学んでいきます。

前提知識: useStateuseEffect の基本を理解していることを前提にしています(#11〜#13 を先に読んでおくと理解がスムーズです)。

Fetch APIの基本

fetch()ブラウザに標準搭載されているAPIで、追加インストールなしで使えます。

Reactでは通常 useEffect の中でデータを取得します。

「コンポーネントが表示されたタイミングで1回だけAPIを叩く」という流れが基本パターンです。

なぜ3つのstateが必要なのか

APIを扱うには、次の3つの状態を常に意識します。

state役割
data(今回は users取得したデータそのもの
loading通信中かどうか(ローディング表示の制御)
errorエラーが起きたか、どんなエラーか

この3点セットはほぼすべてのAPI処理で登場します。

基本的なデータ取得

JavaScript版:

JSX
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 UserList

TypeScript版:

TypeScript版では、APIレスポンスの型を interface で定義します。

useState にも型引数を渡してミスを防ぎます。

TSX
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 UserList

async/awaitを使った書き方(推奨)

.then チェーンより読みやすく、エラーハンドリングも一か所にまとまるのでこちらが主流です。

ただし useEffect に渡す関数は直接 async にできないため、内側に async 関数を定義して呼び出すのがポイントです。

を定義して呼び出すのがポイントです。

jsx

JSX
// ❌ これはできない
useEffect(async () => { ... }, [])

// ✅ こうする
useEffect(() => {
  const fetchData = async () => { ... }
  fetchData()
}, [])

JavaScript版:

このコードは、ページを開いてからデータが画面に表示されるまで、次の順番で動きます。

JSX
コンポーネントが画面に表示される

useEffect が起動しfetchUsers() を呼び出す

loading: true になり読み込み中...」が表示される

fetch() でAPIにリクエストを送る(※ここで一時停止して待つ

    ┌─ 成功 ──────────────────────────┐
    │ ⑤a response.ok を確認
    │ ⑥a データをJSONに変換
    │ ⑦a users に保存
    └─────────────────────────────────┘
    ┌─ 失敗 ──────────────────────────┐
    │ ⑤b catch に飛ぶ
    │ ⑥b error にメッセージを保存
    └────────────────────────────────┘
      ↓(成功失敗どちらでも
finally: loading: false になる

error があればエラー表示なければユーザー一覧を表示
JSX
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版:

TSX
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 UserList

Axiosを使ったデータ取得

Fetch API vs Axios — どちらを使うべきか

Fetch APIAxios
インストール不要(ブラウザ標準)npm install axios が必要
JSONの自動変換response.json() を手動で呼ぶ必要あり自動response.data で取得)
HTTPエラーの検知手動で response.ok をチェック自動で例外を投げてくれる
インターセプター自前で実装が必要リクエスト/レスポンスの一元管理が可能
タイムアウト設定やや複雑簡単

個人プロジェクトや小規模ならFetch APIで十分。

チーム開発や認証ヘッダーなどの共通処理が多い場合はAxiosが便利です。

Axiosのインストール

Bash
npm install axios

基本的な使い方

JavaScript版:

JSX
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版:

TSX
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

インターセプター

インターセプターとは、すべてのリクエスト・レスポンスを「通過する共通の関門」です。

認証トークンの付与やエラーの一括処理など、毎回書かなくていい共通処理をまとめるのに使います。

インターセプターを使わないと、トークン付与やエラー処理をコンポーネントごとに毎回書く必要があります。

リクエストからレスポンスまでの往復にあたって、前処理や後処理のタイミングとイメージすると良いです。

TSX
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

作成した apiClientaxios の代わりに使うだけで、すべてのリクエストに共通処理が自動で適用されます。

TSX
// axios ではなく apiClient を使う
const response = await apiClient.get<User[]>('/users')

タイムアウト設定

APIが応答を返さない場合、何秒待ったら諦めるかを指定する設定です。

指定しないと応答が返るまで永遠に待ち続けることになります。

TSX
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' になるので、専用のエラーメッセージを出せます。

TSX
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 を使えば同じことはできますが、コードが増えます。

TSX
// 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:

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版:

JSX
//
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版:

TSX
// types/index.ts 末尾に追加

export interface User {
  id: number
  name: string
  email: string
}
TSX
// 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
TSX
// 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
TSX
 // 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
TSX
// 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 の値だけで出し分けています。

TSX
{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枚」と決め打ちしてスケルトンを並べます。

TSX
Array(6).fill(0).map((_, i) => <SkeletonCard key={i} />)
//  ↑ 長さ6の配列  ↑ 0で埋める  ↑ 値は使わないので _ 、i は key に使う

③ CSSアニメーションで「読み込み中」感を演出する

スケルトンの見た目自体はただのグレーのボックスです。animation: pulse を当てることで点滅し、「何かが読み込まれている」ことをユーザーに伝えます。

CSS
.skeleton {
  animation: pulse 1.5s infinite; /* 1.5秒周期で無限に繰り返す */
}

@keyframes pulse {
  0%, 100% { opacity: 1; }    /* 完全に表示 */
  50%       { opacity: 0.5; } /* 半透明 */
}

エラーハンドリング

エラーを console.error だけで処理すると、ユーザーは何が起きたかわかりません。

「再試行ボタン付きのエラー表示」を実装することで、ユーザーが自力で回復できる余地を作ります。

詳細なエラー表示

JavaScript版:

UserListコンポーネントのファイルにまとめて書いています。

エラーのコンポーネントを分割してみてるなど試してください。

JSX
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版:

TSX
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版:

JSX
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 CreateUser

TypeScript版:

TSX
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 CreateUser

PUTリクエスト(データ更新)

パスパラメータで更新対象を指定する事とメソッドがPUTを指定しているところがポイントです。

JavaScript版:

JSX
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版:

JSX
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 DeleteUser

TypeScript版:

TSX
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版:

JSX
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版:

TSX
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呼び出し回数を大幅に削減できます。

JavaScript
入力: "r""re""rea""reac""react"
                                     ↑ 500ms後にここでAPIを叩く

まずカスタムフックを作っておきます:

JavaScript
// 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 useDebounce

TS版

TypeScript
// 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 useDebounce

JavaScript版:

JSX

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版:

TSX
// 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版:

JSX
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 TodoApp

TypeScript版:

TSX
// 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. カスタムフックに抽出

同じデータ取得ロジックを複数コンポーネントで使い回したいとき、カスタムフックにまとめるとコードの重複が減り、テストもしやすくなります

JSX
// 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

使い方がシンプルになります:

JSX
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 を使えば、クリーンアップ時にリクエストを安全にキャンセルできます。

JSX
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をコンポーネントにハードコードすると、本番環境への切り替えのたびに書き換えが必要になります。環境変数を使いましょう。

Bash
# .env(プロジェクトルートに作成)
VITE_API_URL=https://api.example.com
JSX

// 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

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

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 / trueboolean省略可
0 / 1 などnumber省略可
'' / 'hello' などstring省略可
[]never[]必須
nullnull必須

まとめ

この記事では、ReactでAPIを扱う全体像を学びました。

基本の流れ

TSX
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と連携しながら、実践的なスキルを身につけていきましょう!