404 Not Found

404 Not Found


nginx

TanStack Query(React Query)

前回の授業で、トムはカスタムフックとaxiosを使ってHTTPリクエストをラップしましたが、新たな問題が発生しました。ダッシュボードとサイドバーの両方にユーザー数が表示されており、各コンポーネントが独自にリクエストを送信しているため(帯域幅とサーバーリソースを浪費しています)、ユーザーがページAでユーザー名を変更しても、ページBのデータは更新されないままになります。また、編集フォームを送信した後、ユーザーは手動でデータの更新を実行しなければなりません。彼は次のように気づきました。APIデータを、キャッシュされ、有効期限があり、自動的に同期できる特別な状態として扱うためには、「サーバーサイドの状態管理」ソリューションが必要だ


1. 学習内容


2. 概念図

100%
flowchart TD
    A[useQuery Call] --> B{Cache Hit?}
    B -->|No matches found| C[Initiate API Request]
    B -->|Hit but Expired| C
    B -->|On Target and Fresh| D[Return cached data directly]
    C --> E[Cached Data]
    E --> F[Rendering Component]
    F --> G{staleTime Has it expired??}
    G -->|Not yet due| H[Data labeled as"Fresh"]
    G -->|Expired| I[Data labeled as"Expired"]
    I --> J{Bring the window back to the foreground?}
    J -->|is | C
    I --> K{refetchInterval?}
    K -->|is | C
    I --> L{There's something new useMutation<br/>invalidate?}
    L -->|is | C

    style A fill:#e1f5fe,stroke:#0288d1
    style E fill:#fff3e0,stroke:#f57c00
    style H fill:#e8f5e9,stroke:#388e3c
    style I fill:#ffcdd2,stroke:#d32f2f

TanStack Queryの基本的な仕組み:まずキャッシュから読み込む → 有効か期限切れかを判定 → 期限切れの場合は自動的に新しいリクエストをトリガーする。


3. 実際の事例

トムのダッシュボードには、ユーザー総数、注文総数、および最近の注文一覧を表示する必要があります。これらのデータは3つの異なるAPIから取得されるものであり、リアルタイムで最新の状態に保たれる必要があります。つまり、ユーザーが別のページでデータを変更してダッシュボードに戻った際、最新の結果が表示されるようにする必要があります。さらに、「商品を追加」フォームを送信した後、手動でページを更新することなく、商品リストが自動的に更新されるようにする必要があります。

(1) 問題:キャッシュの手動管理が難しすぎる

TanStack Queryがなければ、トムは自分でそれを処理しなければならなかった:

JSX
// Manually Manage the Cache — You have to write similar logic for each component.
function Dashboard() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch('/api/stats').then(res => res.json())
      .then(d => { setData(d); setLoading(false) })
      .catch(e => { setLoading(false) })
  }, [])

  // Question 1:Switch to another page and then come back → Resubmit Request(Waste!)
  // Question 2:If another component also needs the same data → Duplicate Request
  // Question 3:The data does not refresh automatically,Users may be seeing data from a few minutes ago
}

TanStack Query は、たった 1 つの useQuery フックで、上記のすべての問題を解決します。

特集 手動フェッチ + useEffect TanStack Query
キャッシュ管理 キャッシュなし。削除時にデータが失われる 自動キャッシュ。queryKey によって管理される
重複リクエスト 同じデータを使用する複数のコンポーネント → 重複リクエスト 重複を自動的に削除し、リクエストは1回のみ
自動更新 なし ウィンドウのフォーカス変更時/再接続時の自動取得
バックエンドの更新 なし staleTime でバックエンドの更新間隔を制御
読み込み/エラーの状態 手動で管理される読み込み/エラー 自動的に提供されるデータ/読み込み/エラー
楽観的な最新情報 手動での実装 onMutate + onError のロールバック
BASH
npm install @tanstack/react-query

(2) プロバイダーの初期化

JSX
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// Create QueryClient(Usually at app Entrance)
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30 * 1000,      // Default 30 Data within seconds is considered"Fresh"
      cacheTime: 5 * 60 * 1000,  // Cache Retention 5 minutes
      retry: 3,                  // Failure retry 3 times
      refetchOnWindowFocus: true, // Refresh when the window returns to the foreground
    }
  }
})

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

4. useQuery:データの取得

(1) ▶ サンプル:ダッシュボードデータの取得

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

// Encapsulated API Function
async function fetchDashboardStats() {
  const response = await fetch('/api/dashboard/stats')
  if (!response.ok) throw new Error('Failed to retrieve statistics')
  return response.json()
}

function Dashboard() {
  // useQuery One line of code replaced useState + useEffect + Manual Caching
  const {
    data: stats,        // Response Data
    isLoading,          // Loading for the first time(When there is no cache)
    isFetching,         // Is a request being made?(Including background retries)
    error,              // Error Object
    refetch             // Manually Trigger Retrieval
  } = useQuery({
    queryKey: ['dashboardStats'],   // Unique Identifier,Used for caching matches
    queryFn: fetchDashboardStats,   // Data Retrieval Functions
    staleTime: 30 * 1000,           // 30 Do not resend the request within seconds
    retry: 3,                       // Failure retry 3 times
  })

  if (isLoading) return <DashboardSkeleton />
  if (error) return <ErrorPanel message={error.message} onRetry={refetch} />

  return (
    <div className="dashboard">
      <StatCard title="Total Number of Users" value={stats.users} />
      <StatCard title="Total Number of Orders" value={stats.orders} />
      <StatCard title="Total Revenue" value={`$${stats.revenue}`} />
    </div>
  )
}

// The sidebar also displays the number of users — Using the same queryKey,No duplicate requests!
function Sidebar() {
  const { data: stats } = useQuery({
    queryKey: ['dashboardStats'],
    queryFn: fetchDashboardStats,
    staleTime: 30 * 1000,
  })

  return (
    <aside>
      <p>Users Online:{stats?.onlineUsers ?? '...'}</p>
    </aside>
  )
}
▶ 試してみよう

主な仕組み:同一の queryKey インスタンスは同じキャッシュを共有します。 ダッシュボードとサイドバーは同じ ['dashboardStats'] を使用しており、TanStack Query がリクエストの重複を自動的に排除します。各コンポーネントは 1 回の API リクエストのみを発行し、データが返されると両方のコンポーネントが同時に更新されます。

(2) ▶ サンプル:パラメータを含むクエリ

JSX
function ProductDetail({ productId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['product', productId],    // queryKey Includes parameters
    queryFn: async () => {
      const res = await fetch(`/api/products/${productId}`)
      if (!res.ok) throw new Error('The product does not exist.')
      return res.json()
    },
    enabled: !!productId,    // productId Do not send a request if it is empty
    staleTime: 60 * 1000,
  })

  if (isLoading) return <p>Loading product details...</p>
  if (error) return <p>Error:{error.message}</p>

  return (
    <div>
      <h2>{data.name}</h2>
      <p className="price">${data.price}</p>
      <p>{data.description}</p>
    </div>
  )
}
▶ 試してみよう

queryKeyに含まれるパラメータの意義: TanStack Queryでは、queryKeyをキャッシュの一意の識別子として使用します。['product', 1]['product', 2]は、互いに干渉しない2つの独立したキャッシュです。productIdが1から2に変化すると、システムはキャッシュからの['product', 2]の読み取りを優先します。キャッシュされており、有効期限が切れていない場合は直接レンダリングされ、そうでない場合はリクエストを送信します。

(3) ▶ サンプル:useQuery によって返される主要フィールド

フィールド 意味 ユースケース
data 最後に正常に応答した際のデータ UIの表示
isLoading キャッシュデータがない場合の初回読み込み 初回読み込み時にスケルトン画面を表示
isFetching 進行中のリクエスト(バックグラウンドでの再試行を含む) バックグラウンド更新インジケーターを表示
error 失敗したリクエストのエラーオブジェクト エラーメッセージを表示
refetch 手動で再リクエストを実行する機能 「更新」ボタン
isStale データは古くなっていませんか? 条件に応じて更新の案内を表示

5. useMutation:データの書き込み

読み取りには useQuery を、書き込みには useMutation を使用してください。これが TanStack Query の鉄則です。

(1) ▶ サンプル:商品を追加してリストを更新する

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

// Product List Search
function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('/api/products')
      return res.json()
    }
  })
}

function ProductManager() {
  const queryClient = useQueryClient()

  // Add a product mutation
  const addProductMutation = useMutation({
    mutationFn: async (newProduct) => {
      const res = await fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newProduct)
      })
      if (!res.ok) throw new Error('Failed to add')
      return res.json()
    },
    // Steps to Take After Success:Invalidate the product list cache,Trigger a re-request
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] })
      // Optional:Display a success message at the same time
      alert('Item added successfully!')
    },
    // Error Handling
    onError: (error) => {
      alert(`Failed to add:${error.message}`)
    }
  })

  // Data
  const { data: products, isLoading } = useProducts()

  // Form Submission
  function handleSubmit(event) {
    event.preventDefault()
    const formData = new FormData(event.target)
    const newProduct = {
      name: formData.get('name'),
      price: Number(formData.get('price'))
    }
    addProductMutation.mutate(newProduct)
    event.target.reset()
  }

  return (
    <div>
      <h2>Product Management</h2>

      <form onSubmit={handleSubmit}>
        <input name="name" placeholder="Product Name" required />
        <input name="price" type="number" placeholder="Price" required />
        <button type="submit" disabled={addProductMutation.isLoading}>
          {addProductMutation.isLoading ? 'Submitting......' : 'Add Item'}
        </button>
      </form>

      {addProductMutation.isError && (
        <p className="error">Submission Failed:{addProductMutation.error.message}</p>
      )}

      <hr />

      <h3>Product List</h3>
      {isLoading && <p>Loading......</p>}
      {products && (
        <ul>
          {products.map(p => (
            <li key={p.id}>{p.name} — ${p.price}</li>
          ))}
        </ul>
      )}
    </div>
  )
}
▶ 試してみよう

主な処理の流れ: useMutation.mutate() が POST リクエストを送信 → サーバーがリクエストを処理 → onSuccess がコールバック内で invalidateQueries を実行 → TanStack Query が自動的に ['products'] のデータを再取得 → リスト UI が自動的に更新される。

setDataを手動で利用してみてはいかがでしょうか? queryClient.setQueryDataを呼び出してキャッシュを手動で更新することも可能ですが、より良い方法は、TanStack QueryにinvalidateQueriesを介してサーバーからデータを再リクエストさせることです。これにより、データが常にサーバーから取得されることが保証され、フロントエンドのキャッシュとサーバー側のデータとの間に不整合が生じるのを防ぐことができます。


6. 楽観的更新

ネットワークの遅延が大きい場合、ユーザーはリクエストを送信した後、サーバーからの応答を待たなければ UI の変更を確認できず、その結果、ユーザー体験が低下してしまいます。楽観的更新では、リクエストを送信する前に UI を即座に更新し、リクエストが失敗した場合は以前の状態にロールバックします。

(1) ▶ サンプル:タスクの完了ステータスの切り替え

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

// Get the To-Do List
function useTodos() {
  return useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const res = await fetch('/api/todos')
      return res.json()
    }
  })
}

function TodoList() {
  const queryClient = useQueryClient()
  const { data: todos } = useTodos()

  // Switch to "Completed" status — Using Optimistic Updates
  const toggleMutation = useMutation({
    // Actual API Request
    mutationFn: async ({ id, done }) => {
      const res = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ done })
      })
      if (!res.ok) throw new Error('Update Failed')
      return res.json()
    },

    // ===== Optimistic Update Begins =====
    onMutate: async ({ id, done }) => {
      // 1. Cancel all ongoing todos Search(Avoid Overwriting Optimistic Updates)
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // 2. Save the current cache data,Used for rollback in case of failure
      const previousTodos = queryClient.getQueryData(['todos'])

      // 3. Refresh the cache immediately
      queryClient.setQueryData(['todos'], (old) =>
        old.map(todo =>
          todo.id === id ? { ...todo, done } : todo
        )
      )

      // 4. Restore old data for rollback
      return { previousTodos }
    },
    // ===== End of Optimistic Update =====

    // Rollback on Failure
    onError: (err, variables, context) => {
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos)
      }
    },

    // Whether we succeed or fail,Finally, resend the request to ensure synchronization with the server.
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })

  return (
    <ul>
      {todos?.map(todo => (
        <li key={todo.id} style={{ opacity: toggleMutation.isLoading ? 0.7 : 1 }}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => toggleMutation.mutate({ id: todo.id, done: !todo.done })}
          />
          <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  )
}
▶ 試してみよう

楽観主義への3ステップガイド:

  1. onMutate: リクエストを送信する直前にキャッシュを更新し、ユーザーが変更内容をすぐに確認できるようにする。また、ロールバックが必要になった場合に備えて、古いデータを保存しておく。
  2. onError: リクエストが失敗した際に、保存された履歴データを使用してキャッシュを復元(ロールバック)する
  3. onSettled: 成功・失敗にかかわらず、最終的にはサーバーからデータを再度取得し、完全な一貫性を確保する。

7. サーバーサイドの状態とクライアントサイドの状態

TanStack Query の背後にある概念を理解するには、次の 2 つの状態を区別する必要があります:

ディメンション サーバーの状態 クライアントの状態
ソース バックエンドAPI/データベース フロントエンド(ローカル、ユーザー操作)
所有権 サーバーがデータを所有する フロントエンドがデータを所有する
永続性 データベースに保存 メモリまたはlocalStorageに保存
同期要件 サーバーとの同期を維持する必要がある サーバーとの同期を維持する必要はない
更新方法 API経由での書き込み+再取得 setStateを直接使用
管理ツール TanStack Query State / Redux Toolkit
ユーザー一覧、商品データ、注文情報 ポップアップの表示/非表示切り替え、フォームの入力値、テーマカラー

基本原則:

(1) ▶ サンプル:TanStack クエリ DevTools

TanStack Query には、開発中にすべてのクエリのキャッシュ状態、有効期限、および最終更新時刻を確認できる専用の DevTools コンポーネントが用意されています。

BASH
npm install @tanstack/react-query-devtools
JSX
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      {/* Display only in the development environment DevTools */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

DevToolsの機能:

  1. すべてのqueryKeysと、それに対応するキャッシュされたデータを表示する
  2. 各キャッシュエントリの「期限切れ」、「アクティブ」、「非アクティブ」のステータスを確認する
  3. 再取得、無効化、削除の操作を手動で実行する
  4. ネットワークの遅延とリクエストの応答サイズを監視する

(2) ▶ サンプル:QueryClientのグローバルメソッド

useQueryuseMutation に加え、queryClient オブジェクトも、コンポーネントの外部からキャッシュを操作するためのいくつかのグローバルメソッドを提供しています:

JSX
import { queryClient } from './queryClient'

// 1. Invalidate the cache(Trigger a re-request)
queryClient.invalidateQueries({ queryKey: ['products'] })

// 2. Clear All Cache(When a user logs out)
queryClient.clear()

// 3. Set the default option(Global Changes staleTime)
queryClient.setDefaultOptions({
  queries: { staleTime: 60 * 1000 }
})

// 4. Retrieve Cached Data(Do not trigger a request)
const cachedData = queryClient.getQueryData(['products'])

// 5. Prefetch Data(User hover Preload when the link is reached)
queryClient.prefetchQuery({
  queryKey: ['product', '42'],
  queryFn: () => fetchProduct('42')
})
// After the user clicks the link,,The data is already in the cache.,Instant Rendering

// 6. Cancel the current query
queryClient.cancelQueries({ queryKey: ['product', '42'] })
▶ 試してみよう

データのプリフェッチ(prefetchQuery)は、ユーザー体験を向上させる効果的な手法です。ユーザーがリンクにカーソルを合わせると、事前にリクエストが送信されるため、ユーザーがクリックする時点でデータがすでに読み込まれており、「瞬時読み込み」のような効果が得られます。

(3) ▶ サンプル:QueryClientの設定をカスタマイズすべき場合

初期化時に定義された defaultOptions はグローバルなデフォルト値を設定しますが、各 useQuery の呼び出しで個別に上書きすることができます:

JSX
// Global Default:30 Freshness Period (in Seconds)
const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 30 * 1000, retry: 3 }
  }
})

// A Query Override:The data remains virtually unchanged,Set the shelf life to 10 minutes
function useProductCategories() {
  return useQuery({
    queryKey: ['categories'],
    queryFn: fetchCategories,
    staleTime: 10 * 60 * 1000,  // 10 Do not retry within minutes
  })
}

// Another query override:High real-time requirements,Disable Caching
function useRealtimeNotifications() {
  return useQuery({
    queryKey: ['notifications'],
    queryFn: fetchNotifications,
    staleTime: 0,               // The data expires immediately
    refetchInterval: 30 * 1000, // Auto-poll every 30s
  })
}
▶ 試してみよう

設定の原則: グローバル設定では控えめな値(短い staleTime)を設定し、データの特性に応じてクエリごとに個別に上書きします。頻繁に変化するデータ(通知、リアルタイム統計)については、短い staleTime を設定するか、ポーリングを有効にします。変化頻度の低いデータ(カテゴリ一覧、設定情報)については、リクエスト数を減らすために長い staleTime を設定します。


❓ よくある質問

Q useEffectfetch を使用する場合と比べて、TanStack Query の主な利点は何ですか?
A 3つの主な利点があります:(1) キャッシュの共有—複数のコンポーネントが同じ queryKey を使用する場合、重複するリクエストを防ぐために重複が自動的に除外されます; (2) 自動再取得—ウィンドウがフォアグラウンドに戻ったとき、ネットワークが再接続されたとき、または定期的なポーリング中に、データが自動的に更新されます;(3) ライフサイクル管理—読み込み、エラー、データ状態の自動処理;失敗時の自動再試行;リクエストをキャンセルするためにAbortControllerを手動で実装する必要がありません。1つのuseQueryで、20行分のuseEffect + fetch + useStateを置き換えることができます。
Q staleTimecacheTimeの違いは何ですか?staleTime = 0 の場合、どうなりますか?
A staleTime はデータの「鮮度」を制御します。この時間枠内では、データは最新とみなされ、自動的な再取得はトリガーされません。cacheTime はキャッシュの保持時間を制御します。これは、コンポーネントがアンマウントされた後、キャッシュがガベージコレクションされるまでの期間です。staleTime = 0 の場合、データは返された瞬間に期限切れとしてマークされ、使用されるたびにバックグラウンドでの再取得がトリガーされます(ただし、キャッシュされたデータが最初に返され、その後更新されます)。リクエストのジッターを避けるため、staleTime = 30s に設定することをお勧めします。
Q useMutationonSuccessメソッドにおいて、invalidateQueriessetQueryDataのどちらを選択すべきですか?
A 推奨されるのはinvalidateQueriesです。キャッシュを無効化してTanStack Queryにサーバーからのデータ再取得を強制し、データの整合性を確保します。setQueryDataは、変更がごくわずかで、返される形式が既知の場合(クライアント側の一意のIDを生成する場合など)に適しています。ユーザーの操作結果を(サーバーからの応答を待たずに)即座に表示する必要がある場合は、setQueryDataの代わりに楽観的更新(onMutate + ロールバック)を使用してください。
Q 複数のコンポーネントが同じ queryKey を使用する場合、TanStack Query はいつ新しいリクエストをトリガーするかをどのように判断しますか?
A ルールは以下の通りです:(1) キャッシュヒットし、かつデータの有効期限が切れていない場合(staleTime 以内)、リクエストを送信せずにキャッシュされたデータを直接返します; (2) キャッシュヒットしたが、キャッシュの有効期限が切れている場合は、キャッシュされた結果を直ちに返し、バックグラウンドで新しいリクエストを開始します; (3) キャッシュが存在しない場合は、リクエストを開始します。同じ queryKey を購読しているコンポーネントの数がいくつであっても、送信されるリクエストは1つだけです。
Q TanStack Query と Zustand は併用できますか?
A はい、このアプローチが推奨されます。TanStack Query はサーバーサイドの状態(API データ)を管理し、Zustand はクライアントサイドの状態(UI 状態)を管理します。一般的なアーキテクチャは以下の通りです:TanStack Queryがデータを取得してキャッシュし → さらなる処理のためにそのデータをZustandストアに注入し → コンポーネントがZustandから最終的な状態を読み取ります。この2つは互いに補完し合うものであり、互いに代替し合うものではありません。

📖 まとめ


📝 練習問題

  1. useQuery を使用してユーザーリストコンポーネントを作成する:API として https://jsonplaceholder.typicode.com/users を使用し、キャッシュの共有を実装する(両方のコンポーネントで同じ queryKey を使用し、リクエストが 1 回だけ送信されるようにする)。また、手動リフレッシュボタンを追加する。
  2. useMutation を使用して「商品の追加」機能を実装します。フォームが送信されると POST リクエストがトリガーされ、成功した場合は商品リストが自動的に更新され、失敗した場合はエラーメッセージが表示されます。
  3. 「ToDo項目の完了ステータスを切り替える」ための楽観的更新を実装します。チェックボックスをクリックすると、即座にステータスが切り替わります。APIリクエストが失敗した場合は、変更がロールバックされます。コードを記述した後、ネットワーク接続を切断し、チェックボックスをクリックしてロールバックの挙動を確認してください。
Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%