最近、状態管理ライブラリ TanStack Query
を使う機会がありました。
TanStack Query
は公式ドキュメントが充実しています。
- TanStack Query | React Query, Solid Query, Svelte Query, Vue Query
- TanStack/query: 🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.
- Practical React Query | TkDodo's blog
一方、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
example=case1
とすると、OpenAPIスキーマのexample
に記載した内容をレスポンスするdynamic=true
とすると、x-faker
で指定したFaker.jsのダミーデータをレスポンスする- https://docs.stoplight.io/docs/prism/9528b5a8272c0-dynamic-response-generation-with-faker
- Faker.jsで指定できる内容は、以下です
- 今回は int や fish を使っています
なお、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-generator
の typescript-axios
を使って、OpenAPIスキーマからPrsimと通信するときのクライアントを自動生成することにしました。
- OpenAPITools/openapi-generator: OpenAPI Generator allows generation of API client libraries (SDK generation), server stubs, documentation and configuration automatically given an OpenAPI Spec (v2, v3)
- openapi-generator/docs/generators/typescript-axios.md at master · OpenAPITools/openapi-generator
ファイルベースルーティング: 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 />)
とありました。
そこで今回、 QueryClientProvider
を render
に渡したところ、うまく動作しました。
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イメージが公開されているので、そちらを利用します。
- https://github.com/OpenAPITools/openapi-generator#16---docker
- https://github.com/stoplightio/prism/blob/master/docs/getting-started/01-installation.md#docker-compose
今回は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からデータを取得し、マージした結果を表示する
次は以下の流れを実装してみます。
- ボタンを押す
- りんご情報を取得するAPIを呼ぶ (
fetchApples
) - 上記2のレスポンスを元に、色情報を取得するAPIを呼ぶ (
fetchColor
) - 2つのAPIの結果をマージして画面に表示する
- 処理が成功した旨を画面に表示する
ボタンをクリックする前
ボタンをクリックした後
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であり、次のメジャーバージョンからは削除されるようです。
- RFC: remove callbacks from useQuery · TanStack/query · Discussion #5279
- Breaking React Query's API on purpose | TkDodo's blog
今回は両者を試してみましたが、将来的なことを考えると 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.id
や fish.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
の中で、 createFish
と fetchFishes
を直列に呼んでいます。
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
を使うことで、キャッシュが古くなったとみなしてデータの再取得が行われます。
- queryClient.invalidateQueries | QueryClient | TanStack Query Docs
- Query Invalidation | TanStack Query Docs
そこで、公式ドキュメントに従い、 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