React・実務の道具立て

データ取得は自前で書かない(TanStack Query)

実務でuseEffect+fetchを手書きすることはほとんどありません。自前だと何が足りないのかを具体的に並べてから、TanStack Queryに置きかえます。置きかえるとContextごと不要になる、というところまでやります。

effectの回で、useEffectfetchでデータを読みこみました。あのとき「実務ではライブラリに任せることが多い」と書いたまま先に進んでいたので、ここで回収します。

自前だと何が足りないのか

いまのuseBirds(Context版)は、これだけです。

useEffect(() => {
  fetch('/birds.json')
    .then((response) => response.json())
    .then((data) => {
      setBirds(data)
      setIsLoading(false)
    })
}, [])

動きます。ただ、実務のアプリに必要なものがごっそり抜けています

足りないもの自前で書くと
失敗の扱い.catchとエラー用のstateを足す
再取得「更新」ボタン、画面に戻ったときの取り直し
重複リクエスト同じデータを2つの画面が使うと2回通信する
競合状態検索語を速く打つと、古い結果があとから届いて上書きする
キャッシュ一度見たデータを覚えておく
再試行通信が失敗したとき、少し待ってもう一度

全部書くとuseBirdsは100行を超えます。しかもアプリのどのデータ取得にも同じものが要ります

TanStack Queryに置きかえる

デファクトスタンダードがTanStack Query(旧React Query)です。

npm install --save-exact @tanstack/react-query

アプリの上のほうを、専用のProviderで包みます。

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

const queryClient = new QueryClient()

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </BrowserRouter>
  </StrictMode>,
)

そして読み出しはuseQueryひとつになります。

import { useQuery } from '@tanstack/react-query'
import type { Bird } from '../types.ts'

async function fetchBirds(): Promise<Bird[]> {
  const response = await fetch('/birds.json')
  if (!response.ok) {
    throw new Error('読みこめませんでした')
  }
  return response.json()
}

export function useBirds() {
  const { data: birds = [], isPending, isError } = useQuery({
    queryKey: ['birds'],
    queryFn: fetchBirds,
  })

  return { birds, isPending, isError }
}

useEffectuseStateも消えました。 読みこみ中(isPending)も失敗(isError)も、最初から付いてきます。

Contextが要らなくなる

ここが今回いちばん大きい変化です。データ取得のために作ったContextは、まるごと削除できます。

contextの回でContextを入れた理由は「一覧ページと詳細ページで同じデータを共有したいから」でした。TanStack Queryではキャッシュそのものが共有の置き場なので、両方のページがuseBirds()を呼ぶだけで同じデータを見ます。

これまで: BirdsProvider(fetch+state)→ Context → 各ページ
これから: 各ページが useBirds() を呼ぶ → キャッシュが1つ

更新はuseMutation

サーバーにデータを送るとき(追加・更新・削除)はuseMutationを使います。

const queryClient = useQueryClient()

const addBirdMutation = useMutation({
  mutationFn: (newBird) => fetch('/api/birds', { method: 'POST', body: JSON.stringify(newBird) }),
  onSuccess: () => {
    // 成功したら一覧のキャッシュを「古い」印にする → 自動で取り直される
    queryClient.invalidateQueries({ queryKey: ['birds'] })
  },
})

送る → 成功したらinvalidateQueries → 一覧が最新になる。この流れが実務での定番です。

このコースのアプリにはサーバーが無いので、キャッシュを直接書きかえる形にしておきます。

function addBird(newBird) {
  queryClient.setQueryData(['birds'], (current = []) => [...current, newBird])
}

入れるか、入れないか

小さなアプリに常に必要なわけではありません。

状況判断
読みこみが1か所だけ・使い捨てuseEffectfetchのままでよい
同じデータを複数の画面で使う入れる
検索・ページ送りで何度も取り直す入れる
更新して一覧を最新にしたい入れる
Next.jsなどのフレームワークを使っているフレームワーク側の仕組みを先に見る
ReactuseEffectで外のデータを読むuseEffectで自前に書いた回。「Effectは最後の手段」と書いた意味が、ここでつながります

公式ドキュメントも見てみる

TanStack外部サイトQuick Start | TanStack Query React DocsTanStack Query公式のクイックスタート。QueryClientProvideruseQueryuseMutationの最小の形がまとまっています(英語)

このレッスンのまとめ

  1. 自前のuseEffectfetchに足りないのは、失敗・再取得・重複・競合状態・キャッシュ・再試行
  2. いちばん怖いのは行数ではなく、競合状態のような見つけにくいバグを自作すること
  3. useQueryuseEffectuseStateも消える。isPendingisErrorが付いてくる
  4. queryKeyはキャッシュの住所。キーに検索語を含めると、変わったときだけ取り直す
  5. データ取得のためのContextは削除できる(テーマ等のContextは残る)
  6. 更新はuseMutationinvalidateQueries
  7. staleTimeを指定しないと、画面に戻るたびに取り直される

次は、増えすぎたフォームをライブラリに任せる回です。

やってみよう:TanStack Queryに置きかえる

Contextでのデータ取得を、useQueryに移します。

  1. npm install --save-exact @tanstack/react-query
  2. main.tsx<QueryClientProvider client={queryClient}>に包む(BirdsProviderと入れかえ)
  3. src/hooks/useBirds.tsを作り、useQuery/birds.jsonを取る(staleTime: Infinityを付ける)
  4. addBirdremoveBirdqueryClient.setQueryDataで書く
  5. 各ページのimportを新しいuseBirdsに向け、isLoadingisPendingに直す
  6. src/context/BirdsContext.tsx削除する
  7. 一覧 → 詳細 → 一覧と移動して、追加した鳥が消えないことを確認する
import { useQuery, useQueryClient } from '@tanstack/react-query'
import type { Bird } from '../types.ts'

async function fetchBirds(): Promise<Bird[]> {
  const response = await fetch('/birds.json')
  if (!response.ok) throw new Error('読みこめませんでした')
  return response.json()
}

export function useBirds() {
  const queryClient = useQueryClient()
  const { data: birds = [], isPending, isError } = useQuery({
    queryKey: ['birds'],
    queryFn: fetchBirds,
    staleTime: Infinity,
  })

  function addBird(newBird: Bird) {
    queryClient.setQueryData<Bird[]>(['birds'], (current = []) => [...current, newBird])
  }

  function removeBird(id: Bird['id']) {
    queryClient.setQueryData<Bird[]>(['birds'], (current = []) =>
      current.filter((bird) => bird.id !== id),
    )
  }

  return { birds, isPending, isError, addBird, removeBird }
}

7番をstaleTime無しでも試してみてください。 追加した鳥が消えます。消える理由が説明できたら、この回は身についています。

よくある質問

useEffectとfetchで書くのはだめなのですか?
だめではありませんが、読みこみ中・失敗・再取得・重複リクエスト・競合状態を全部自分で書くことになります。実務では、それらが最初から入っているライブラリを使うほうが安全で速いという判断がふつうです。
TanStack Queryは何をしてくれますか?
取得したデータをキーごとにキャッシュし、読みこみ中や失敗の状態を渡し、必要なときだけ取り直します。同じデータを2か所で使っても通信は1回で済みます。
queryKeyとは何ですか?
キャッシュの住所です。同じキーなら同じデータとして扱われ、キーに検索語などを含めておけば、その値が変わったときだけ取り直されます。
Contextは要らなくなるのですか?
データ取得のために作ったContextは要らなくなります。キャッシュ自体が共有の置き場になるからです。テーマやログイン情報のようなデータ取得以外のContextは、そのまま使い続けます。