KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

useQueryを使った非同期処理と状態管理のスマートな解決法

こんにちは!最近はスポーツカーの動画を見るのにハマっている、フロントエンドエンジニアの Y.K です。

フロントエンド開発において、データフェッチの管理は意外と悩ましいポイントのひとつですよね。 Reactで非同期処理を書いていて、「これ、もっとスマートにできないの?」と頭を抱えた経験、ありませんか?

そんな中で注目されているのが、TanStack Query(旧React Query)で、 非同期処理や状態管理を驚くほどシンプルにしてくれるライブラリです。

今回はその中でも特に重要な useQuery にフォーカスし、実際の使用感や具体的なコード例を交えながらご紹介していきます。

Reactだけでの管理

ReactでAPIからデータを取得する際、useState と useEffect を使った実装はよく見かけます。 例えば、以下のようなコードを書いたことがある方も多いのではないでしょうか?

const [data, setData] = useState()
  
useEffect(() => {
   const fetchData = async () => {
     const response = await fetch(`/api/data/hoge`);
     const json = await response.json();
     setData(json);
   }
   fetchData();
 }, [])

このコードは、ページの初回ロード時にデータを取得し、useState で管理するという基本的なパターンです。 しかし、これだけでは取得中のローディング表示やエラー処理が抜けており、実運用には不十分です。

それらを考慮すると、次のようなコードになります。

const [data, setData] = useState();
const [pending, setPending] = useState(false);
const [error, setError] = useState(false);

useEffect(() => {
  const fetchData = async () => {
    try {
      setPending(true);
      setError(false);

      const response = await fetch(`/api/data/hoge`);
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }

      const json = await response.json();
      setData(json);
    } catch {
      setError(true);
    } finally {
      setPending(false);
    }
  };

  fetchData();
}, []);

たった1つのデータ取得でも、ここまでの状態管理が必要になると、コードが煩雑になり、保守性も下がってしまいます。

このような課題を解決してくれるのがuseQuery です。

使い方

まず基本的な使い方を見てみましょう!

const { data, status } = useQuery({ 
  queryKey: ["data"], 
  queryFn: async () => await fetch("/api/data/hoge").then(res => res.json())
})

このコードでは、useQuery の戻り値である data に取得したデータが格納され、status には "success", "pending", "error" の3つの状態が入ります。 それぞれ、通信が成功した状態・通信中・通信中にエラーが発生した状態を表しています。

つまり、data と status を見るだけで、先ほどのような複雑な状態管理は不要になるというわけです。

次は少し発展的な使い方を見てみましょう!

const [name,  setName] = useState("hoge")

const useDataFetchQuery = (name: string) => useQuery({
  queryKey: ["data", name], 
  queryFn: async () => await fetch(`/api/data/${name}`).then(res => res.json()),
  enable: !!name
})

const { data, status } = useDataFetchQuery(name)

このコードでは、先ほどと同様に dataとstatusに値が格納されますが、nameの値が変わると自動的にデータが再取得されるようになっています。
つまり、ユーザーの入力に応じてデータを切り替えるような、レスポンシブな対応が簡単に実現できるのです。

このように、複雑な状態管理を意識することなく、シンプルにデータ取得を扱えるのが useQuery の大きな魅力です。

ここからは最近の開発で役に立った便利機能3選をご紹介します!!

便利機能3選

networkMode

networkMode は、ネットワークの接続状態に応じて useQuery の実行を制御できるオプションです。 デフォルトでは "online" が指定されており、オフライン状態では useQuery が自動的に起動しない仕様になっています。

実際に私が開発している製品でも、オフライン時にデータを更新させたいという要件がありました。 そのようなケースでは、networkMode: "always" を指定することで、ネットワーク状態に関係なく useQuery を起動させることができます。

select

APIから取得したデータの中から必要な情報だけを抽出したり、構造を変えて扱いたいときに便利なのが、select オプションです。 select を使うことで、クエリ関数(queryFn)から返されたデータをその場で加工し、useQuery の data として扱うことができます。

実際の開発では、クエリ関数の中でデータの加工まで行ってしまうと、TypeScriptの型推論がうまく効かず、TypeError が発生することがあります。 そこで select を使うと、データの取得と加工を明示的に分離でき、型推論が正しく働くようになります。これにより、コードの保守性や可読性も向上します。

また、クエリ関数から返された値はキャッシュされますが、select から返された値はキャッシュされません。 そのため、同じキャッシュされたデータを元に、異なる加工方法で使い分けるといった柔軟な設計も可能になります。

refetch

queryKey が変更されたとき以外にも、任意のタイミングでデータを再取得したい場面は多くあります。 そんなときに便利なのが、useQuery の戻り値に含まれる refetch 関数です。

この関数を呼び出すことで、明示的にクエリを再実行し、最新のデータを取得することができます。

便利機能を使った実装

ここまでで、useQuery の基本的な使い方から応用的なオプションまで、さまざまな機能をご紹介してきました。 最後に、それらを組み合わせた実践的な実装を行っていきます。 ただし、その前に React と JSON Server を使ったモック API 通信を行うための環境構築が必要です。 以下の記事を参考に、開発環境を整えておいてください。

ESLint × Prettier × Vite(React) 環境構築 - KENTEM TechBlog

JSON ServerでモックアップAPIを作成 #React - Qiita

また、作成したReactのターミナルで以下のコマンドを実行するとTanStack QueryとAxiosをインストールできます!

npm install @tanstack/react-query axios

それでは実装は以下のようになります。

import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={new QueryClient()}>
    <App />
  </QueryClientProvider>
)
import { useState } from 'react'
import './App.css'
import { useQuery } from '@tanstack/react-query'
import type { AxiosError } from 'axios'
import axios from 'axios'

export type CarData = {
  "id": string,
  "name": string,
  "manufacturer": string,
  "productionStart": number,
  "productionEnd": number,
  "nickname": string,
  "maxPower": number,
  "originalPrice": number,
  "currentUsedPrice": number
}

const App = () => {
  const [name, setName] = useState("AE86")

  return (
    <div className="app">
      <input value={name} onChange={(e) => setName(e.target.value)}></input>
      {name ? <CarInfo name={name} /> : <p>Please enter a name</p>}
    </div>
  )
}

const CarInfo = ({ name }: { name: string }) => {
  const { data, error, status, refetch } = useCarInfoQuery(name)

  const renderItem = (() => {
    switch (status) {
      case "pending":
        return <p>Loading...</p>
      case "error":
        return <p>Error: {error?.response?.data || error?.message}</p>
      case "success":
        return (
          <>
            <div id={data.id}>
              <h2>{data.title}</h2>
              <p>ニックネーム: {data.nickname}</p>
              <p>製造: {data.productionStart} ~ {data.productionEnd}</p>
              <p>最大出力: {data.maxPower}ps</p>
              <p>新車価格: {data.originalPrice.toLocaleString()}円</p>
              <p>中古価格: {data.currentUsedPrice.toLocaleString()}円</p>
            </div>
            <button onClick={async () => await refetch()}>Refetch</button>
          </>
        )
    }
  })()

  return (
    <>{renderItem}</>
  )
}

// queryFnで返ってくる値の型(このデータ型がキャッシュされる)
type QueryFnReturn = CarData
// queryFnで発生するエラーの型
type QueryError = AxiosError<string>
// selectで返ってくる値の型(このデータ型はキャッシュされない)
type SelectReturn = Omit<CarData, "name" | "manufacturer"> & { title: string }

const useCarInfoQuery = (name: string) =>
  useQuery<QueryFnReturn , QueryError, SelectReturn>({
    queryKey: ["car", name],
    queryFn: async () => await axios.get<QueryFnReturn>(`http://localhost:3003/${name}`)
      .then(res => res.data),
    select: (data) => ({
      ...data,
      title: `${data.name}(${data.manufacturer})`
    }),
    networkMode: "always",
    enable: !!name
  })

export default App
// このモックデータは生成AIで作成しています。
// 情報に間違いを含む可能性がありますのでご注意ください。
{
  "AE86": {
    "id": "toyota-ae86-trueno",
    "name": "SPRINTER TRUENO",
    "manufacturer": "Toyota",
    "productionStart": 1983,
    "productionEnd": 1987,
    "nickname": "ハチロク",
    "maxPower": 130,
    "originalPrice": 1480000,
    "currentUsedPrice": 3500000
  },
  "S13": {
    "id": "nissan-s13-silvia",
    "name": "Silvia",
    "manufacturer": "Nissan",
    "productionStart": 1988,
    "productionEnd": 1994,
    "nickname": "シルビア",
    "maxPower": 205,
    "originalPrice": 1980000,
    "currentUsedPrice": 2800000
  },
  "RPS13": {
    "id": "nissan-rps13-180sx",
    "name": "180SX",
    "manufacturer": "Nissan",
    "productionStart": 1989,
    "productionEnd": 1998,
    "nickname": "ワンエイティ",
    "maxPower": 205,
    "originalPrice": 2150000,
    "currentUsedPrice": 3200000
  },
  "FC3S": {
    "id": "mazda-fc3s-rx7",
    "name": "RX-7",
    "manufacturer": "Mazda",
    "productionStart": 1985,
    "productionEnd": 1992,
    "nickname": "サバンナ",
    "maxPower": 185,
    "originalPrice": 2390000,
    "currentUsedPrice": 4200000
  },
  "BNR32": {
    "id": "nissan-bnr32-gtr",
    "name": "Skyline GT-R",
    "manufacturer": "Nissan",
    "productionStart": 1989,
    "productionEnd": 1994,
    "nickname": "ゴジラ",
    "maxPower": 280,
    "originalPrice": 4450000,
    "currentUsedPrice": 15000000
  },
  "EG6": {
    "id": "honda-eg6-civic",
    "name": "Civic",
    "manufacturer": "Honda",
    "productionStart": 1991,
    "productionEnd": 1995,
    "nickname": "シビック",
    "maxPower": 170,
    "originalPrice": 1620000,
    "currentUsedPrice": 2200000
  },
  "AP1": {
    "id": "honda-ap1-s2000",
    "name": "S2000",
    "manufacturer": "Honda",
    "productionStart": 1999,
    "productionEnd": 2009,
    "nickname": "エスニセン",
    "maxPower": 250,
    "originalPrice": 3890000,
    "currentUsedPrice": 5500000
  }
}

特に CarInfo コンポーネントでは、通信状態(pending / error / success)に応じたUIの出し分けを行い、refetch によって手動でデータを再取得できる仕組みを実装しています。

今回の例では、refetch の恩恵は少ないように見えるかもしれませんが、JSON Server 側のデータを手動で変更したあとにボタンを押すことで、実際にデータが更新される様子を確認できます。 こうした仕組みは、ユーザー操作による明示的な再取得が必要な場面で役立ちます。

まとめ

今回はuseQuery を使った、データの取得・状態管理・表示の工夫について解説してきました。 しかし、実際のアプリケーション開発では「取得(GET)」だけでなく、登録・更新・削除(POST / PUT / DELETE)といった操作も欠かせません。

おそらく多くの読者の方も、「取得はわかったけど、それ以外はどうするの?」と気になっているのではないでしょうか?

次回以降は、useMutation を使ったデータの登録・更新・削除処理にフォーカスします。 フォーム送信やボタン操作による状態変更、キャッシュの更新方法など、実践的なユースケースを交えながら解説していきます。

どうぞお楽しみに!

参考文献

tanstack.com

おわりに

KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp