Reactにて、useStateやuseEffectを使っていたところをTanStack Queryに置き換えてみた

最近、状態管理ライブラリ TanStack Query を使う機会がありました。

TanStack Query は公式ドキュメントが充実しています。

 
一方、TanStack Queryの機能がとても多く、公式ドキュメントを読むだけでは理解しきれないところもありました。

そこで、「 useState などを使って実装していたものをTanStack Queryで置き換える」素振りをしてみたので、その時のメモを残します。

 

目次

 

環境

  • Windows11 + WSL2
  • React 18.2.0
  • TanStack Query 4.32.6
  • Vite 4.4.5
  • Prism
  • openapi-generator
    • typescript-axios
  • Generouted
    • @generouted/react-router 1.15.3
  • React Router 6.14.2

 
なお、React + TanStack Queryを試すために、他にも色々環境構築をしたため、メモを残しておきます。

 

ビルドツール: Vite

今回、Viteを使ってReactをセットアップしました。
Vite | 次世代フロントエンドツール

 

モックバックエンドサーバ: Prism

TanStack Queryを使ってバックエンドと通信するためには、バックエンドサーバが必要です。

そこで、今回はOpenAPIスキーマを元にモックサーバを建てる Prism を使うことにしました。
stoplightio/prism: Turn any OpenAPI2/3 and Postman Collection file into an API server with mocking, transformations and validations.

 
ちなみに、Prismでバックエンドのレスポンスデータを切り替えたい場合、 Prefer HTTPヘッダを使うと便利です。
Modifying Responses | Prism CLI | Prism

 
なお、Prismではサポートしていない機能もいくつかあるようです。

  • Multipart requests and responses, such as multipart/form-data
  • Binary files, such as PDFs, image files, and zip archives

https://docs.stoplight.io/docs/prism/1593d1470e4df-concepts#content-negotiation

APIクライアント: typescript-axios

バックエンドと通信するときに axios などでリクエストを飛ばす処理を書くのも手間でした。

そこで、 openapi-generatortypescript-axios を使って、OpenAPIスキーマからPrsimと通信するときのクライアントを自動生成することにしました。

 

ファイルベースルーティング: Generouted + React Router

今回は複数のパターンの素振りをしたいため、Reactで使えるルーティングライブラリも必要でした。

ただ、ルーティングを考えたり設定したりするのは手間なので、ファイルベースでルーティングしたくなりました。

調べてみたところ、Vite + Generouted + React Router などのルーティングライブラリを組み合わせることで、ファイルベースルーティングができそうでした。

Generouted を軽くさわってみたところ、今回のサンプルコードを動かすのには問題なさそうだったため、使ってみることにしました。

 
なお、TanStack Queryを使う場合、main.tsなどに

// https://tanstack.com/query/latest/docs/react/overview#enough-talk-show-me-some-code-already
const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

のような QueryClientProvider を設定してあげる必要があります。

 
GeneroutedのREADMEを見ると、ルーティングの設定方法は

// https://github.com/oedotme/generouted#usage

import { createRoot } from 'react-dom/client'
import { Routes } from '@generouted/react-router'

const app = document.getElementById('app')
createRoot(app).render(<Routes />)

とありました。

 
そこで今回、 QueryClientProviderrender に渡したところ、うまく動作しました。

import {createRoot} from 'react-dom/client'
import {Routes} from '@generouted/react-router'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'

const queryClient = new QueryClient()

const container = document.getElementById('root')!

// QueryClientProviderをrenderに渡す
createRoot(container).render(<QueryClientProvider client={queryClient}><Routes/></QueryClientProvider>)

 
ちなみに、Generoutedを使うときは各コンポーネントdefault export する必要がありました。

const Page = () => {}

// generouted の場合、 default export が必要
export default Page

// 以下では動作しない
// export const Page = () => { return <h1>Hello</h1> }

 
また、 src/router.ts はGeneroutedが自動で生成・編集するファイルのようです。

 

環境構築のログ

ViteでReact環境を作ります。
https://ja.vitejs.dev/guide/

$ npm create vite@latest
Need to install the following packages:
  create-vite@4.4.1
Ok to proceed? (y) y
✔ Project name: … tanstack_prism_generouted_example
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Scaffolding project in path/to/tanstack_prism_generouted_example...

Done. Now run:

  cd tanstack_prism_generouted_example
  npm install
  npm run dev

 
必要な以下のライブラリはnpmでインストールします。

  • TanStack Query
  • Vite
  • Generouted
  • React Router

 
また、

  • Prism
  • openapi-generator

は公式でDockerイメージが公開されているので、そちらを利用します。

 
今回はOpenAPIスキーマを見やすくするためのReDocもDockerで動かすため、以下の compose.yml を用意し、Docker Composeで起動できるようにします。

services:
  prism:
    image: stoplight/prism:4
    command: 'mock -h 0.0.0.0 /tmp/openapi.yml'
    volumes:
      - ./openapi/openapi.yml:/tmp/openapi.yml
    ports:
      # Serve the mocked API locally as available on port 8301
      - '8301:4010'
  redoc:
    image: redocly/redoc
    volumes:
      - ./openapi:/usr/share/nginx/html/api
    environment:
      SPEC_URL: api/openapi.yml
    ports:
      - 8080:80
  openapi_generator:
    image: openapitools/openapi-generator-cli:v6.6.0
    volumes:
      - ./:/local
    command: generate -i local/openapi/openapi.yml -g typescript-axios -o local/tanstack_prism_generouted_example/types

 
これにより、

  • docker compose up -d で、Prismを起動
  • docker compose run --rm openapi_generator で、OpenAPIスキーマを元にOpenAPIクライアントを生成

ができるようになりました。

 
ここまでで準備が完了したため、次は実際に実装していきます。

 

データ取得系の置き換え

まずはデータの取得系を置き換えてみます。

なお、今回TanStack Queryを使うときは、TanStack Queryをラップしたカスタムフックを合わせて作成することにします。

また、エラーハンドリングについては色々なパターンがあると考えられるので、今回は考慮・実装していません。

データの初期ロード

URLを開くと、データが初期ロードされている画面の実装です。

 

useState + useEffectでの実装

useState で画面に表示するデータを保持しておき、 useEffect でデータを取得・反映しています。

import {useEffect, useState} from "react";
import {Apple, DefaultApi} from "../../../../types";

const Page = () => {
  const [apples, setApples] = useState<Apple[]>([])

  useEffect(() => {
    const fetchApples = async() => {
      const response = await new DefaultApi().fetchApples()
      setApples(response.data.apples)
    }
    fetchApples()
  }, [])

  return (
    <>
      <h1>結果(useEffect)</h1>
      {apples.map((a) => {
        return <p key={a.id}>{a.name}</p>
      })}
    </>
  )
}

export default Page

 

TanStack Queryでの実装

まずは、TanStack Queryの useQuery を使ったカスタムフック useInitialLoad.ts を用意します。
Queries | TanStack Query Docs

import {useQuery} from "@tanstack/react-query";
import {DefaultApi, ApplesResponse} from "../../types";

const queryFn = (): Promise<ApplesResponse> => new DefaultApi().fetchApples().then((response) => response.data)

export const useInitialLoad = () => {
  return useQuery({
    queryKey: ['initialLoad'],
    queryFn: queryFn
  })
}

 
続いてカスタムフックを利用するコンポーネントを実装します。

APIリファレンスにある通り、 isLoading でローディング中を判断しつつ、 data の中にあるデータを表示します。
https://tanstack.com/query/v4/docs/react/reference/useQuery

import {useInitialLoad} from "../../../hooks/useInitialLoad";

const Page = () => {
  const {data, isLoading} = useInitialLoad()

  if (isLoading) return <div>Loading</div>
  if (!data) return

  return (
    <>
      <h1>結果(TanStack Query)</h1>
      {data.apples.map((a) => {
        return <p key={a.id}>{a.name}</p>
      })}
    </>
  )
}

export default Page

 

ボタンを押すとデータを表示する

最初はデータが画面に表示されていませんが、ボタンを押すとデータを表示する画面です。

ボタンをクリックする前

 
ボタンをクリックした後

 

useStateでの実装

useState を使います。

ボタンをクリックするとバックエンドAPIを呼び、その結果を画面に表示します。

import {Apple, DefaultApi} from "../../../../types";
import {useState} from "react";

const Page = () => {
  const [apples, setApples] = useState<Apple[]>([])

  const handleClick = async () => {
    const response = await new DefaultApi().fetchApples()
    setApples(response.data.apples)
  }

  return (
    <>
      <h1>結果(useState)</h1>
      {apples.map((a) => {
        return <p key={a.id}>{a.name}</p>
      })}

      <button onClick={handleClick}>取得</button>
    </>
  )
}

export default Page

 

TanStack Queryでの実装

初期ロードの実装に加え、 enabled: false を追加しています。

enabled: false としたことで、TanStack Queryの実行を refetch を実行するときまで遅らせています。
Disabling/Pausing Queries | TanStack Query Docs

import {useQuery} from "@tanstack/react-query";
import {DefaultApi, ApplesResponse} from "../../types";

const queryFn = (): Promise<ApplesResponse> => new DefaultApi().fetchApples().then((response) => response.data)

export const useClick = () => {
  return useQuery({
    queryKey: ['useClick'],
    queryFn: queryFn,
    enabled: false // 追加
  })
}

 
続いてコンポーネントです。

コンポーネントではカスタムフックから refetch を取り出し、 handleClick の中で refetch を実行しています。

これにより、ボタンを押すと画面にデータが表示されます。

import {useClick} from "../../../hooks/useClick";

const Page = () => {
  const {data, refetch} = useClick()

  const handleClick = async () => {
    await refetch()
  }

  return (
    <>
      <h1>結果(TanStack Query)</h1>
      {data?.apples.map((a) => {
        return <p key={a.id}>{a.name}</p>
      })}

      <button onClick={handleClick}>取得</button>
    </>
  )
}

export default Page

 

ボタンを押すと複数APIからデータを取得し、マージした結果を表示する

次は以下の流れを実装してみます。

  1. ボタンを押す
  2. りんご情報を取得するAPIを呼ぶ ( fetchApples )
  3. 上記2のレスポンスを元に、色情報を取得するAPIを呼ぶ ( fetchColor )
  4. 2つのAPIの結果をマージして画面に表示する
  5. 処理が成功した旨を画面に表示する

 

ボタンをクリックする前

 

ボタンをクリックした後

 

useStateでの実装

handleClickの中で

  • APIの結果をマージした値をstateに保存
  • 処理が成功した情報をstateに保存

と、各stateを更新する処理を行っています

import {useState} from "react";
import {DefaultApi} from "../../../../types";

type AppleWithColor = {
  id: number
  name: string
  colorName: string
}

const Page = () => {
  const [appleWithColorList, setAppleWithColorList] = useState<AppleWithColor[]>([])
  const [isSuccess, setIsSuccess] = useState(false)

  const handleClick = async () => {
    const response = await new DefaultApi().fetchApples()
    const {data: {apples}} = response

    const results = await Promise.all(apples.map(async (apple) => {
      if (!apple.colorId) {
        return {
          id: apple.id,
          name: apple.name,
          colorName: '秘密'
        }
      }

      // PrismのPreferヘッダを使い、colorIdの値によりレスポンスしてもらうexampleの値を変更している
      const colorResponse = await new DefaultApi().fetchColor(apple.colorId, {headers: {Prefer: `example=case${apple.colorId}`}})
      return {
        id: apple.id,
        name: apple.name,
        colorName: colorResponse.data.colorName
      }
    }))

    setAppleWithColorList(results)

    setIsSuccess(true)
  }

  return (
    <>
      <h1>結果(useState)</h1>
      {appleWithColorList.map((a) => {
        return <p key={a.id}>名前: {a.name} ({a.colorName})</p>
      })}

      <button onClick={handleClick}>取得</button>

      {isSuccess && (
        <p>取得に成功しました</p>
      )}
    </>
  )
}

export default Page

 

TanStack Queryでの実装

TanStack Queryで実装する場合

  • useQueryの onSuccess コールバックを使う
  • useEffect と組み合わせて使う

の2つの実装方法があります。

ちなみに、useQueryにある onSuccess などのコールバックはdeprecatedであり、次のメジャーバージョンからは削除されるようです。

 
今回は両者を試してみましたが、将来的なことを考えると useEffect と組み合わせて実装したほうが良さそうです。

なお、 useMutation のコールバックはdeprecatedではありません。

 

useQueryのonSuccessコールバックを使う (deprecated)

今回は、カスタムフックを使うときに onSuccess コールバック関数を受け取るようにしました。

import {useQuery} from "@tanstack/react-query";
import {ApplesResponse, DefaultApi} from "../../types";

const queryFn = (): Promise<ApplesResponse> => new DefaultApi().fetchApples().then((response) => response.data)

export const useChainWithOnSuccess = ({onSuccess}: {onSuccess: (data: ApplesResponse) => Promise<void>}) => {
  return useQuery({
    queryKey: ['useChainWithOnSuccess'],
    queryFn: queryFn,
    enabled: false,
    onSuccess: onSuccess // 追加
  })
}

 
続いてコンポーネントです。

マージした値をuseStateで保存するところは変わらず、カスタムフックにコールバック関数を渡しています。

import {useState} from "react";
import {useChainWithOnSuccess} from "../../../../hooks/useChainWithOnSuccess";
import {ApplesResponse, DefaultApi} from "../../../../../types";

type AppleWithColor = {
  id: number
  name: string
  colorName: string
}

const Page = () => {
  const [appleWithColorList, setAppleWithColorList] = useState<AppleWithColor[]>([])
  const [isSuccess, setIsSuccess] = useState(false)

  const {refetch} = useChainWithOnSuccess({onSuccess: async (data: ApplesResponse) => {
      const results = await Promise.all(data.apples.map(async (apple) => {
        if (!apple.colorId) {
          return {
            id: apple.id,
            name: apple.name,
            colorName: '秘密'
          }
        }

        const colorResponse = await new DefaultApi().fetchColor(apple.colorId, {headers: {Prefer: `example=case${apple.colorId}`}})
        return {
          id: apple.id,
          name: apple.name,
          colorName: colorResponse.data.colorName
        }
      }))

      setAppleWithColorList(results)
      setIsSuccess(true)
    }
  })

  const handleClick = async () => {
    await refetch()
  }

  return (
    <>
      <h1>結果(TanStack Query with onSuccess)</h1>
      {appleWithColorList.map((a) => {
        return <p key={a.id}>[onSuccess] 名前: {a.name} ({a.colorName})</p>
      })}

      <button onClick={handleClick}>取得</button>

      {isSuccess && (
        <p>取得に成功しました</p>
      )}
    </>
  )
}

export default Page

 

useEffectと組み合わせて使う

カスタムフックでは fetchApples のみ実装しています。 fetchColor は呼ぶたびに引数の colorId が異なることから、キャッシュしなくても良さそうなためです。

import {useQuery} from "@tanstack/react-query";
import {ApplesResponse, DefaultApi} from "../../types";

const queryFn = (): Promise<ApplesResponse> => new DefaultApi().fetchApples().then((response) => response.data)

export const useChainWithUseEffect = () => {
  return useQuery({
    queryKey: ['useChainWithUseEffect'],
    queryFn: queryFn,
    enabled: false,
  })
}

 
続いて、コンポーネントの実装です。

onSuccess コールバックの中にあった処理を useEffect へと移動しています。

また、 useEffect の依存配列にカスタムフックの data を入れています。これにより、data に変更があれば画面に反映されるようになります。

import {useEffect, useState} from "react";
import {DefaultApi} from "../../../../../types";
import {useChainWithUseEffect} from "../../../../hooks/useChainWithUseEffect";

type AppleWithColor = {
  id: number
  name: string
  colorName: string
}

const Page = () => {
  const [appleWithColorList, setAppleWithColorList] = useState<AppleWithColor[]>([])
  const [isSuccess, setIsSuccess] = useState(false)

  const {refetch, data} = useChainWithUseEffect()

  useEffect(() => {
    const fetchAndMerge = async () => {
      if (!data) return

      const results = await Promise.all(data.apples.map(async (apple) => {
        if (!apple.colorId) {
          return {
            id: apple.id,
            name: apple.name,
            colorName: '秘密'
          }
        }

        const colorResponse = await new DefaultApi().fetchColor(apple.colorId, {headers: {Prefer: `example=case${apple.colorId}`}})
        return {
          id: apple.id,
          name: apple.name,
          colorName: colorResponse.data.colorName
        }
      }))

      setAppleWithColorList(results)
      setIsSuccess(true)
    }

  fetchAndMerge()

  }, [data])


  const handleClick = async () => {
    await refetch()
  }

  return (
    <>
      <h1>結果(TanStack Query with useEffect)</h1>
      {appleWithColorList.map((a) => {
        return <p key={a.id}>[useEffect] 名前: {a.name} ({a.colorName})</p>
      })}

      <button onClick={handleClick}>取得</button>

      {isSuccess && (
        <p>取得に成功しました</p>
      )}
    </>
  )
}

export default Page

 

データ更新系の置き換え

ここまでで、TanStack Queryの useQuery を使ったデータ取得系の置き換えをためしてきました。

続いて、TanStack Queryの useMutation によるデータ更新系の置き換えをためしていきます。

 

ボタンを押すとデータをPOSTし、レスポンスを画面に反映する

まずはデータのPOSTのみを行います。

POSTを受け付けるAPIのレスポンスは以下のような形です。

> curl -X POST -H "Content-Type: application/json" -d "{\"name\":\"mackerel\"}" http://localhost:8301/fishes
{"fish":{"id":1,"name":"鯖"}}

これらの fish.idfish.name を画面に反映します。

ボタンをクリックする前

 
ボタンをクリックした後

 
ちなみに、今回の環境ではWindows Terminalの curl を使っているため、POSTでは

  • d の外側はダブルクォート
  • JSONデータは \"

とする必要があります。
Windows版curlでJSONをPOSTする際に困った話 - Qiita

 
また、OpenAPIスキーマを使っている都合上、POSTされた動的なデータをレスポンスに含めることができません。

そのため、POSTデータやレスポンスデータ( {"fish":{"id":1,"name":"鯖"}} ) は固定値です。

 

useStateでの実装

レスポンスデータを保存するために useState を使っています。

import {useState} from "react";
import {DefaultApi, Fish} from "../../../../../types";

const Page = () => {
  const [fish, setFish] = useState<Fish>()

  const createFish = async() => {
    const response = await new DefaultApi().createFish({name: 'さんま'})
    setFish(response.data.fish)
  }

  const handleClick = async () => {
    await createFish()
  }

  return (
    <>
      <h1>結果(useState)</h1>

      {fish && (
        <>
          <p>ID: { fish.id }</p>
          <p>Name: { fish.name }</p>
        </>
      )}

      <button onClick={handleClick}>更新</button>
    </>
  )
}

export default Page

 

useMutationでの実装

TanStack Queryでは、データ更新に useMutation を使います。

 
まずは useMutation をラップしたカスタムフックを用意します。

import {useMutation} from "@tanstack/react-query";
import {DefaultApi, FishParams} from "../../types";

export const useCreateFish = () => {
  return useMutation({
    mutationFn: (params: FishParams) => new DefaultApi().createFish(params),
  })
}

 
続いて、上記のカスタムフックを使うコンポーネントです。

mutate を実行すると、APIのレスポンスが data に反映されます。

import {useCreateFish} from "../../../../hooks/useCreateFish";

const Page = () => {
  const {data, mutate} = useCreateFish()

  const handleClick = () => {
    mutate({name: 'さんま'})
  }

  return (
    <>
      <h1>結果(TanStack Query)</h1>

      {data && (
        <>
          <p>ID: { data.data.fish.id }</p>
          <p>Name: { data.data.fish.name }</p>
        </>
      )}

      <button onClick={handleClick}>登録</button>
    </>
  )
}

export default Page

 
 

ボタンを押すとデータを登録し、画面のデータを再取得する

次は、データ登録後にデータの再取得をためしてみます。

なお、PrismによるレスポンスデータにはPOSTデータを含められないため、今回は「POSTしたら新しいFakerのデータをレスポンスする」としています。

 
ボタンをクリックする前

 
ボタンをクリックした後

分かりづらいですが、レスポンスデータが差し替わっています。

 

useStateでの実装

handleClick の中で、 createFishfetchFishes を直列に呼んでいます。

import {useEffect, useState} from "react";
import {DefaultApi, Fish} from "../../../../../types";

const Page = () => {
  const [fishes, setFishes] = useState<Fish[]>([])

  const createFish = async() => {
    await new DefaultApi().createFish({name: 'さんま'})
  }

  const fetchFishes = async() => {
    const response = await new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}})
    setFishes(response.data.fishes)
  }

  const handleClick = async () => {
    await createFish()
    await fetchFishes()
  }

  useEffect(() => {
    fetchFishes()
  }, [])

  return (
    <>
      <h1>結果(TanStack Query)</h1>
      {fishes && (
        fishes.map((a) => {
          return <p key={a.id}>ID: {a.id} / Name: {a.name}</p>
        })
      )}

      <h2>更新</h2>

      <button onClick={handleClick}>更新</button>
    </>
  )
}

export default Page

 

useMutationでの実装

先ほど作成した useCreateFish の他に、データを取得するカスタムフック useFetchFishes を作成します。

import {DefaultApi, FishesGetResponse} from "../../types";
import {useQuery} from "@tanstack/react-query";

const queryFn = (): Promise<FishesGetResponse> => new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then((response) => response.data)

export const useFetchFishes = () => {
  return useQuery({
    queryKey: ['useFetchFishes'],
    queryFn: queryFn,
  })
}

 
続いてコンポーネントを作成します。

TanStack Queryの場合、 queryClient.invalidateQueries を使うことで、キャッシュが古くなったとみなしてデータの再取得が行われます。

 
そこで、公式ドキュメントに従い、 useMutationの onSuccess コールバックにて invalidateQueries を使ってデータの再取得を行います。
Invalidations from Mutations | TanStack Query Docs

import {useCreateFish} from "../../../../hooks/useCreateFish";
import {useFetchFishes} from "../../../../hooks/useFetchFishes";
import {useQueryClient} from "@tanstack/react-query";

const Page = () => {
  const {mutate} = useCreateFish()
  const {data} = useFetchFishes()

  // invalidateQueriesを使う際、{}では取得できない。
  // https://github.com/TanStack/query/issues/1575
  const queryClient = useQueryClient()

  const handleClick = () => {
    mutate(
      {name: 'さんま'},
      {
        onSuccess: () => {
          queryClient.invalidateQueries({queryKey: ['useFetchFishes']})
        }
      }
    )
  }

  return (
    <>
      <h1>結果(TanStack Query)</h1>
      {data && (
        data.fishes.map((a) => {
          return <p key={a.id}>ID: {a.id} / Name: {a.name}</p>
        })
      )}

      <h2>更新</h2>

      <button onClick={handleClick}>更新</button>
    </>
  )
}

export default Page

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/tanstack_prism_generouted-example

プルリクはこちら。
https://github.com/thinkAmi-sandbox/tanstack_prism_generouted-example/pull/1