※本記事には広告(楽天アフィリエイト)リンクが含まれる場合があります。

Reactアプリで「APIからデータを取って表示する」処理を、useEffect + fetch + useState で毎回手書きしていませんか?最初は動くものの、ローディング管理・エラー処理・キャッシュ・再取得をすべて自分でやろうとすると、コードはすぐに膨らみ、バグの温床になります。

2026年のReact開発では、データ取得は「ライブラリに任せる」のが当たり前になりました。TanStack Query(旧React Query)はダウンロード数でReactライブラリのトップクラスに君臨し、SWRはNext.jsチーム発のシンプル路線として健在、React Router Loaderはフレームワークルーターの機能として型安全なデータ取得を提供します。そして「依存を増やしたくない」場合の素のfetchパターンも、正しい書き方を知っておく必要があります。

本記事では、これら4つのデータフェッチパターンを実際のコード付きで比較し、キャッシュ戦略・ローディング・楽観的更新(Optimistic Update)・エラーハンドリング・TypeScriptでの型安全まで、個人開発者が「今すぐ使える」形で完全解説します。

広告・楽天アフィリエイト

プログラミング技術書

実践寄りの技術書・参考書を探す。

楽天ポイント還元

技術書を楽天で見る →

1. なぜ「useEffect + fetch」ではダメなのか

まず、多くの個人開発者が最初に書くパターンと、その問題点を整理します。React 18以降はStrictModeで開発中にエフェクトが2回実行されるため、素朴な実装だとAPIが二重に呼ばれることもしばしばです。

// 悪い例: useEffect で毎回手書き
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error('API Error')
        return res.json()
      })
      .then((data) => {
        if (!cancelled) {
          setUser(data)
          setLoading(false)
        }
      })
      .catch((e) => {
        if (!cancelled) {
          setError(e)
          setLoading(false)
        }
      })
    return () => { cancelled = true }
  }, [userId])

  if (loading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error.message}</p>
  return <p>{user?.name}</p>
}

この書き方の問題点は以下の通りです。

  • ローディング・エラー・データの3状態を毎回手書き — コンポーネントごとに同じコードをコピペする羽目になる
  • キャッシュが無い — 画面を離れて戻るだけでAPIを再呼び出しする
  • 再取得(refetch)・楽観的更新・ページネーションなどの定番機能を自分で実装するとバグりやすい
  • 競合状態(race condition) — 古いレスポンスが後から届いて新しいデータを上書きする可能性
  • エラー時のリトライが無い — 一瞬のネットワーク断で画面が真っ白になる

これを解決するのが、次節から紹介するデータフェッチライブラリと、フレームワーク標準のデータ取得機構です。なお、APIそのものの設計(RESTの粒度・エラーフォーマット)に不安がある方は、API設計ベストプラクティス完全ガイドもあわせてご覧ください。

2. 2026年のデータフェッチ4パターン比較

2026年時点で、Reactのデータ取得を担う代表的な4パターンを比較します。

項目 TanStack Query SWR React Router Loader 素のfetch(カスタムフック)
作者 TanStack(コミュニティ) Vercel(Next.jsチーム) Remix / React Routerチーム
思想 サーバーステート管理の万能選手 stale-while-revalidate ルーティングと一体の宣言的取得 依存ゼロ・手動制御
キャッシュ 強力(GC・TTL・正規化) ◯(シンプルなKVキャッシュ) ◯(ルーターが保持) ✗(自前実装)
楽観的更新 ◎(mutation + onMutate) △(手動でsetCache) △(action後に再検証)
TypeScript型安全 ◎(ジェネリクスで厳格) ◎(型推論が強力) △(手書き)
学習コスト 中〜高 中(ルーター前提) 低(ただし複雑さが後で増す)
相性の良い構成 SPA / 大規模 / tRPC併用 Next.js / 小〜中規模 React Router v7 / Remix 超小規模 / 1回だけの取得

どれを選べばいい?」の結論を先に言うと、個人開発のWebアプリでサーバーからデータを取得して頻繁に更新するなら TanStack Query が最も汎用的です。Next.jsでApp Routerを使っていて、取得はサーバー側で完結するならLoaderやServer Componentで十分。依存を増やしたくない超小規模ならカスタムフックでも構いません。以下、それぞれを詳しく見ていきます。

2.1 tRPCとの相性も重要

TypeScriptでフルスタックを組むなら、tRPC完全ガイドで解説したtRPC + TanStack Queryの組み合わせが2026年の定番です。tRPCのクライアントは内部でTanStack Queryを使うため、キャッシュ・再取得・楽観的更新の知識がそのまま活きます。逆に「型安全APIを自前で作りたい」場合は、Hono + Cloudflare WorkersでAPIを立てる構成とも相性が良いです。

3. TanStack Queryで実装する(本命)

TanStack Query(旧React Query)は、サーバー由来の状態(サーバーステート)をクライアントで管理するためのライブラリです。インストールはnpm i @tanstack/react-queryの1行。v5系が安定版で、2026年も最も使われているデータフェッチソリューションです。

3.1 プロバイダーのセットアップ

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,      // 1分間は再取得しない
      gcTime: 5 * 60 * 1000,     // 使われなくなったデータは5分で破棄
      retry: 2,                  // 失敗時のリトライ回数
      refetchOnWindowFocus: true // タブ復帰時に自動再取得
    }
  }
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}

3.2 useQuery の基本形

import { useQuery } from '@tanstack/react-query'

type User = { id: string; name: string; email: string }

async function fetchUser(userId: string): Promise<User> {
  const res = await fetch(`/api/users/${userId}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['user', userId],   // このキーでキャッシュが管理される
    queryFn: () => fetchUser(userId)
  })

  if (isLoading) return <p>読み込み中...</p>
  if (isError) return (
    <div>
      <p>エラー: {error.message}</p>
      <button onClick={() => refetch()}>再試行</button>
    </div>
  )
  return <p>{data.name}({data.email})</p>
}

ポイントは queryKey です。['user', userId] のようにキーにパラメータを含めることで、「同じデータへのアクセスはキャッシュを共有する」という挙動が手に入ります。別のコンポーネントが同じキーでuseQueryを呼んでも、APIリクエストは1回にまとまります(重複排除)。

3.3 useMutation で書き込み + 楽観的更新

データの更新(POST/PUT/DELETE)はuseMutationで行います。ここがTanStack Queryの真骨頂で、楽観的更新(サーバー応答を待たずに画面を先に更新し、失敗したらロールバック)を数行で実装できます。

import { useMutation, useQueryClient } from '@tanstack/react-query'

function UserNameEditor({ userId }: { userId: string }) {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: async (newName: string) => {
      const res = await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: newName })
      })
      if (!res.ok) throw new Error('更新に失敗しました')
      return res.json()
    },
    // 楽観的更新
    onMutate: async (newName) => {
      await queryClient.cancelQueries({ queryKey: ['user', userId] })
      const prev = queryClient.getQueryData(['user', userId])
      queryClient.setQueryData(['user', userId], (old: User | undefined) =>
        old ? { ...old, name: newName } : old)
      return { prev } // ロールバック用に以前の値を保存
    },
    onError: (_err, _newName, context) => {
      // 失敗したら以前の値に戻す
      queryClient.setQueryData(['user', userId], context?.prev)
    },
    onSettled: () => {
      // 完了したらサーバーと同期(再取得)
      queryClient.invalidateQueries({ queryKey: ['user', userId] })
    }
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const name = new FormData(e.currentTarget).get('name') as string
        mutation.mutate(name)
      }}
    >
      <input name="name" defaultValue="" placeholder="新しい名前" />
      <button type="submit">更新</button>
      {mutation.isPending && <p>更新中...</p>}
    </form>
  )
}

使ってみた感想: 楽観的更新を自前で実装するとなると「ロールバックのタイミング管理」で死ぬほど苦労しますが、TanStack QueryならonMutateで前の値を保存してonErrorで戻すだけ。チャットの送信やタスクの完了チェックなど「サクサク動いてほしい」操作で威力を発揮します。学習コストはかかりますが、一度覚えると手放せません。

広告・楽天アフィリエイト

メカニカルキーボード

打鍵感で選ぶ開発者向けキーボード。

楽天ポイント還元

キーボードを楽天で見る →

4. SWRで実装する(シンプル路線)

SWRはVercel製のデータフェッチライブラリで、名前の由来はHTTPキャッシュ戦略の stale-while-revalidate。キャッシュから古いデータを即座に表示しつつ、裏で最新データを再取得するというシンプルな思想です。npm i swr で導入できます。

4.1 useSWR の基本形

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then((res) => {
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
})

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading, mutate } = useSWR(
    `/api/users/${userId}`,
    fetcher
  )

  if (isLoading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error.message}</p>
  return <p>{data.name}</p>
}

SWRの特徴は、キーがそのままURLになる点です。TanStack QueryのqueryKeyqueryFnを分ける方式より直感的で、コード量も少なくて済みます。Next.js(Pages Router)での利用実績が長く、SSR(fallbackData)との組み合わせも簡単です。

4.2 再検証とミューテーション

import useSWR from 'swr'

function useUser(userId: string) {
  const { data, mutate } = useSWR(`/api/users/${userId}`, fetcher)

  const updateName = async (newName: string) => {
    // 楽観的更新
    await mutate(
      fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: newName })
      }).then((r) => r.json()),
      {
        optimisticData: (current: User | undefined) =>
          current ? { ...current, name: newName } : current,
        rollbackOnError: true // 失敗時に自動ロールバック
      }
    )
  }

  return { data, updateName }
}

使ってみた感想: SWRは「とにかくシンプルにデータを取得・再取得したい」時に最適です。TanStack Queryと比べると機能は少ない(依存関係の正規化キャッシュや複雑な楽観的更新はやや弱い)ものの、ブログやドキュメントサイトのように「取得して表示するだけ」の用途なら、このシンプルさが最大の武器になります。Next.js App Router以前のプロジェクトや、SPAを手早く作るならSWRで十分です。

5. React Router Loaderで実装する(ルーター一体型)

React Router v7(旧Remixのルーター機能)では、ルート定義にloaderを持たせ、画面遷移の前にデータを取得するパターンが標準です。コンポーネントが描画される時点ではデータが揃っているため、ローディングUIの管理が大幅に減ります。

5.1 ルート定義とLoader

// routes/user.tsx(ファイルベースルーティングの例)
import { useLoaderData } from 'react-router'

export async function loader({ params }: { params: { userId: string } }) {
  const res = await fetch(`/api/users/${params.userId}`)
  if (!res.ok) throw new Response('Not Found', { status: 404 })
  return res.json() // 型がそのまま下に流れる
}

export default function UserPage() {
  const user = useLoaderData<User>() // loader の戻り値が型推論される
  return (
    <div>
      <p>{user.name}({user.email})</p>
    </div>
  )
}

Loaderの良いところは、型安全です。loaderの戻り値の型がuseLoaderDataにそのまま流れるため、APIレスポンスの形が変わるとコンパイルエラーで気づけます。また、画面遷移時にデータ取得を「待ってから」遷移するので、ちらつき(ローディング→表示)を減らせます。

5.2 Actionと再検証

書き込みはactionで行い、完了後にrevalidateを呼ぶとLoaderが再実行され、データが最新化されます。

import { useActionData, Form, useRevalidator } from 'react-router'

export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData()
  const name = String(formData.get('name'))
  const res = await fetch(`/api/users/${params.userId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name })
  })
  return res.json()
}

function UserNameForm() {
  const revalidator = useRevalidator()
  return (
    <Form method="patch">
      <input name="name" placeholder="新しい名前" />
      <button type="submit">更新</button>
      <button type="button" onClick={() => revalidator.revalidate()}>
        最新化
      </button>
    </Form>
  )
}

使ってみた感想: React Router Loaderは「フレームワークの流儀に従う」ことで、データ取得まわりをかなり簡潔にできます。ただし、取得したデータを複数ルートで共有したい場合や、頻繁に再取得・楽観的更新が必要な場面では、TanStack Queryのような専用ライブラリと併用するのが現実的です。React Router v7では両方を組み合わせる構成も公式にサポートされています。

6. 素のfetch + カスタムフックで実装する(依存ゼロ)

「依存を増やしたくない」「取得は1〜2箇所だけ」という場合は、カスタムフックに切り出した素のfetchでも十分です。ただし、以下の「安全な書き方」のルールは守りましょう。

  • アボート(中断)を実装して、アンマウント後のsetStateを防ぐ
  • ローディング・エラー・データの3状態をフックに閉じ込める
  • 可能なら簡易キャッシュ(MapsessionStorage)を持たせる
// useFetch.ts — 依存ゼロのカスタムフック
import { useCallback, useEffect, useRef, useState } from 'react'

type State<T> = {
  data: T | null
  isLoading: boolean
  error: Error | null
}

export function useFetch<T>(url: string | null) {
  const [state, setState] = useState<State<T>>({
    data: null,
    isLoading: !!url,
    error: null
  })
  const abortRef = useRef<AbortController | null>(null)

  const load = useCallback(async () => {
    if (!url) return
    abortRef.current?.abort()
    const controller = new AbortController()
    abortRef.current = controller
    setState((s) => ({ ...s, isLoading: true, error: null }))
    try {
      const res = await fetch(url, { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const data = (await res.json()) as T
      setState({ data, isLoading: false, error: null })
    } catch (e) {
      if ((e as Error).name === 'AbortError') return // 中断は無視
      setState((s) => ({ ...s, isLoading: false, error: e as Error }))
    }
  }, [url])

  useEffect(() => {
    load()
    return () => abortRef.current?.abort() // アンマウント時に中断
  }, [load])

  return { ...state, reload: load }
}

// 使い方
function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error, reload } = useFetch<User>(
    `/api/users/${userId}`
  )
  if (isLoading) return <p>読み込み中...</p>
  if (error) return (
    <div>
      <p>エラー: {error.message}</p>
      <button onClick={reload}>再試行</button>
    </div>
  )
  return <p>{data?.name}</p>
}

使ってみた感想: このパターンは「ライブラリを入れるほどでもない」小規模プロジェクトで重宝します。ただし、ページネーション・楽観的更新・ウィンドウフォーカス時の再取得などが必要になったら、素直にTanStack QueryかSWRに乗り換えるのが賢明です。自作フックを育て続けるより、実績あるライブラリに移行する方がトータルコストは低くなります。

7. 本番で使うための実践テクニック

ここからは、どのライブラリを使う場合でも役立つ、本番品質のデータフェッチを実現するためのテクニックをまとめます。

7.1 エラーハンドリングとリトライ

APIは必ず失敗します。ネットワーク断、レート制限(429)、サーバーエラー(500)を想定し、指数バックオフ付きリトライを入れてください。TanStack Queryはデフォルトでリトライ付き(retry: 2)、SWRもonErrorRetryで調整可能です。素のfetchの場合は自作するか、retry関数を実装します。

// 素のfetchで指数バックオフ付きリトライ
async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
  try {
    const res = await fetch(url)
    if (res.status === 429 || res.status >= 500) {
      throw new Error(`retryable: ${res.status}`)
    }
    return res
  } catch (e) {
    if (retries === 0) throw e
    const delay = 2 ** (3 - retries) * 500 // 500ms → 1s → 2s
    await new Promise((r) => setTimeout(r, delay))
    return fetchWithRetry(url, retries - 1)
  }
}

ユーザー向けのエラーUIとしては、エラーモニタリングツール比較で紹介したSentryなどを併用し、フロントエンドの例外を記録しておくと、個人開発でも「気づかないバグ」を減らせます。

7.2 キャッシュ戦略と再取得タイミング

「どのタイミングで再取得するか」を設計しましょう。定番は以下の3つです。

  • ウィンドウフォーカス時 — タブを戻ってきたときに最新化(TanStack Query / SWRはデフォルト対応)
  • mutation成功後 — 書き込みが終わったら該当クエリをinvalidate(無効化)して再取得
  • 一定時間ごと — 価格・在庫・ステータス系はrefetchIntervalで30秒〜1分ごとに更新

7.3 ページネーションと無限スクロール

一覧データはuseInfiniteQuery(TanStack Query)か、SWRのuseSWRInfiniteで扱います。カーソルベースのページネーションは、オフセット方式よりデータ追加に強いのでおすすめです。

import { useInfiniteQuery } from '@tanstack/react-query'

function ArticleList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['articles'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/articles?cursor=${pageParam}`).then((r) => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    initialPageParam: 0
  })

  const articles = data?.pages.flatMap((p) => p.items) ?? []

  return (
    <div>
      {articles.map((a) => <p key={a.id}>{a.title}</p>)}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? '読み込み中...' : hasNextPage ? 'もっと見る' : 'すべて表示済み'}
      </button>
    </div>
  )
}

7.4 認証付きAPIとトークン更新

認証が必要なAPIを叩く場合は、fetchのラッパー(APIクライアント)を1つ作り、そこにAuthorizationヘッダーと401時のトークン再取得を集約するのが定番です。認証まわりは認証ライブラリ比較ガイドPasskeysガイドも参考にしてください。

// apiClient.ts — 認証・リトライ・JSON変換を集約
let accessToken: string | null = localStorage.getItem('token')

export async function apiClient<T>(
  url: string,
  options: RequestInit = {}
): Promise<T> {
  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
      ...options.headers
    }
  })

  if (res.status === 401) {
    // リフレッシュトークンで再取得して1回だけリトライ
    const refreshRes = await fetch('/api/auth/refresh', { method: 'POST' })
    if (refreshRes.ok) {
      const { token } = await refreshRes.json()
      accessToken = token
      localStorage.setItem('token', token)
      return apiClient<T>(url, options) // 再帰でリトライ
    }
    throw new Error('セッション切れ')
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

7.5 レースコンディション対策

検索ボックスなど「入力のたびにAPIを呼ぶ」UIでは、古いレスポンスが新しいレスポンスを上書きするレースコンディションが起きます。TanStack Queryはキー管理で自動的に防いでくれますが、素のfetchではAbortControllerで前のリクエストを中断するか、リクエストIDを比較して古い結果を捨てる必要があります。

// シンプルな対策: リクエスト順序の比較
const latestRef = useRef(0)
const handleSearch = async (query: string) => {
  const requestId = ++latestRef.current
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
  const data = await res.json()
  if (requestId !== latestRef.current) return // 古い結果は捨てる
  setResults(data)
}

8. まとめ:データフェッチは「設計」で選ぶ

Reactのデータフェッチは、2026年現在「ライブラリ任せ」が標準です。最後に選定の指針をまとめます。

  1. 本命は TanStack Query — キャッシュ・楽観的更新・無限スクロール・tRPC併用までカバーする万能選手。個人開発のWebアプリはこれで間違いない
  2. シンプルに済ませたいなら SWR — キー=URLの直感的なAPI。小〜中規模や、Next.js Pages Routerプロジェクトに◎
  3. React Router v7を使うなら Loader — ルーターと一体の宣言的取得。型安全でローディング管理も減る。複雑な再取得はQueryと併用
  4. 依存ゼロならカスタムフック — 1〜2箇所の取得なら自作でOK。ただしAbortControllerによる中断と3状態管理は必ず実装する
  5. エラー処理・リトライ・認証・レース対策 — ライブラリに頼る部分と自前で実装する部分を明確に分けて設計する

まずは手元のReactプロジェクトにTanStack Queryを入れて、useQueryに置き換えるところから始めてみてください。ローディング・エラー・キャッシュのコードが消えて、コンポーネントが「表示ロジック」だけになる感覚を味わえるはずです。

関連記事として、tRPC完全ガイド(TanStack Queryと組み合わせる型安全API)、API設計ベストプラクティスNext.js完全ガイドTypeScript実践ガイドもあわせてどうぞ。型安全なデータフェッチを身につけて、快適なReact開発を始めましょう。