[Tanstack Query & Orval] 自動生成したprefetchQueryが動かなくて色々工夫した
表題の通り、やりたいことを素直に実装しようとしたら上手くいかず、試行錯誤した話です。
私の認識誤りの可能性もありますので、解決策をご存知の方は是非コメントでご指摘お願いします。
背景
Next.jsでECサイトを想定したフロントエンドを構築していて、商品や顧客情報取得、注文作成処理などのバックエンドへのリクエストを、OpenAPIからOrvalで自動生成しています。
さらに、OrvalにはTanstack Queryをクライアントに設定し、サーバー側の状態管理とデータフェッチをTanstack Queryで行えるようにしています。
- next 15.0.3
- @tanstack/react-query 5.61.3
- orval 7.3.0
やりたいこと
以下のドキュメントに記載の通り、App Routerにおけるサーバーコンポーネントにて、SSRを活用してバックエンドからデータフェッチするように実装を進めていました。
App Routerのデフォルトはサーバーコンポーネントですし、上記を実現することでサーバーサイドで効率的にデータ取得を行うことが目的です。
これを解説してくれている記事もすでにいくつかあったので参考にさせていただきました。ただ、これらではOrvalで自動生成されたコードの実装は取り上げられていなかったため、今回はそこにチャレンジした形です。
Orvalの設定
以下のように、Orvalにおける自動生成の設定を行っています。
ポイントは以下です。
-
output.client
にreact-query
を指定 -
usePrefetch: true
として、Tanstack QueryのprefetchQuery
を使用したリクエストを出力する
import { defineConfig } from "orval"
export default defineConfig({
backend: {
input: {
target: "../../backend/apidef/openapi.yml",
},
output: {
target: "./src/generated/backend.ts",
clean: true,
client: "react-query",
override: {
query: {
useQuery: true,
usePrefetch: true,
},
},
httpClient: "axios",
},
hooks: {
afterAllFilesWrite: ["prettier --write"],
},
},
})
自動生成されたコード
以降の説明で必要なものだけ抜粋します。商品一覧の取得リクエストです。
useGetProducts()
は、TanstackのuseQuery()
、prefetchGetProducts()
は、TanstackのprefetchQuery()
を中で呼び出しています。getProducts()
はaxiosによるリクエスト実行処理が定義され、共通的に内部で使用されます。よって、useGetProducts()
または、prefetchGetProducts()
をアプリケーション側で呼び出すことで、自動生成されたTanstackによるデータフェッチを利用できるわけです。
/**
* Get a list of products
* @summary Get a list of products
*/
export const getProducts = (
params?: GetProductsParams,
options?: AxiosRequestConfig,
): Promise<AxiosResponse<GetProductsResponseResponse>> => {
return axios.get(`/ec-extension/products`, {
...options,
params: { ...params, ...options?.params },
})
}
/**
* @summary Get a list of products
*/
export function useGetProducts<
TData = Awaited<ReturnType<typeof getProducts>>,
TError = AxiosError<
| BadRequestResponse
| NotFoundResponse
| InternalServerErrorResponse
| ServiceUnavailableResponse
>,
>(
params?: GetProductsParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof getProducts>>, TError, TData>
>
axios?: AxiosRequestConfig
},
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> } {
const queryOptions = getGetProductsQueryOptions(params, options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData>
}
query.queryKey = queryOptions.queryKey
return query
}
/**
* @summary Get a list of products
*/
export const prefetchGetProducts = async <
TData = Awaited<ReturnType<typeof getProducts>>,
TError = AxiosError<
| BadRequestResponse
| NotFoundResponse
| InternalServerErrorResponse
| ServiceUnavailableResponse
>,
>(
queryClient: QueryClient,
params?: GetProductsParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof getProducts>>, TError, TData>
>
axios?: AxiosRequestConfig
},
): Promise<QueryClient> => {
const queryOptions = getGetProductsQueryOptions(params, options)
await queryClient.prefetchQuery(queryOptions)
return queryClient
}
Tanstack QueryのQueryClientProviderの設定
こちらは前述のドキュメントの通りで、サーバー側の処理か否かでQueryClient
の取得を出しわけ、サーバー側から呼ばれた際には、毎度QueryClient
を作成するようにします。
"use client"
import {
isServer,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query"
import axios from "axios"
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
function getQueryClient() {
if (isServer) {
return makeQueryClient()
} else {
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
export default function QueryProvider({
children,
}: {
children: React.ReactNode
}) {
const queryClient = getQueryClient()
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACKEND_ENDPOINT
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
サーバーコンポーネントの実装
こちらもOrvalで出力されたprefetchGetProducts()
を利用する以外は、ドキュメントの通りです。prefetchGetProducts()
でサーバーコンポーネント側でデータフェッチを行い、キャッシュに保持しておきます。また、ハイドレーションAPIを活用するために、HydrationBoundary
でクライアントコンポーネントを囲んでいます。
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query"
import prefetchGetProducts from "@/generated/backend"
import ProductListPresenter from "./presenter"
export default async function Page() {
const queryClient = new QueryClient()
await prefetchGetProducts(
queryClient,
{},
{
query: {
queryKey: ["products"],
},
},
)
const dehydratedState = dehydrate(queryClient)
return (
<HydrationBoundary state={dehydratedState}>
<ProductListPresenter />
</HydrationBoundary>
)
}
クライアントコンポーネントの実装
最小限の実装例ですが、以下のような形です。
Orvalを利用して自動生成した場合、TanstackのqueryKey
についても、実装側で指定しなくとも適切な値を設定してくれます。しかし今回は、サーバーコンポーネント側でキャッシュされた値が見つかればクライアントコンポーネント側でフェッチしないことを明示的にするため、queryKey
を指定しています。これによって、キャッシュが見つかればクライアントコンポーネント側ではデータフェッチせずに、キャッシュされた値を使用するため、SSRを活用したデータフェッチができるということになります。
"use client"
import { Product, useGetProducts } from "@/generated/backend"
export default function ProductListPresenter() {
const { isLoading, error, data } = useGetProducts(
{},
{
query: {
queryKey: ["products"],
},
},
)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
...
問題
結論、上記の実装はエラーになります。
⨯ next/dist/compiled/react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.development.js (362:49) @ m
⨯ RangeError: Maximum call stack size exceeded
at String.replace (<anonymous>
原因としては、Orvalで自動生成されたprefetchGetProducts()
で、axiosのリクエスト実行を行うgetProducts()
の戻り値であるAxiosResponse
をそのままキャッシュしようとしている点と考えています。サーバーコンポーネント側で、prefetchGetProducts()
を呼び出して、キャッシュに保存する際にはデータをシリアライズしますが、AxiosResponse
のままではそれが不可能であろうという推測です。
次の解決方法にもつながりますが、TanstackのprefetchQuery()
を直接記述する際の、queryFn
にはAxiosResponse
ではなく、中身のデータを戻り値として設定するので、Orvalで自動生成されたコードとずれが生じています。
queryFn?: QueryFunction<TQueryFnData, TQueryKey, TPageParam> | SkipToken;
解決方法
結論としては、サーバー側ではOrvalで生成された、prefetchGetProducts()
を使用せず、TanstackのprefetchQuery()
を直接記述しました。
そして、queryFn
には、Orvalで生成されたgetProducts()
を使用して、自動生成されたコードを活用しつつ、戻り値はresponse.data
とすることで、適切にキャッシュされるようにします。
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query"
import { getProducts } from "@/generated/backend"
import ProductListPresenter from "./presenter"
export default async function Page() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ["products"],
queryFn: async () => {
const response = await getProducts()
return response.data
},
})
const dehydratedState = dehydrate(queryClient)
return (
<HydrationBoundary state={dehydratedState}>
<ProductListPresenter />
</HydrationBoundary>
)
}
以下は合わせて修正したクライアントコンポーネントの実装です。
queryClient
を呼び出して、getQueryData()を使用してキャッシュからサーバーコンポーネント側でフェッチしておいたデータを呼び出します。
"use client"
import { GetProductsResponse, Product, useGetProducts } from "@/generated/backend"
export default function ProductListPresenter() {
const queryClient = useQueryClient()
let productsData = queryClient.getQueryData<GetProductsResponse>([
"products",
])
if (!productsData) {
const { isLoading, error, data } = useGetProducts()
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
productsData = data?.data
}
...
まとめ
本来はOrvalで生成したものをそのまま使いたかったのですが、いろいろ手元で試したり、実装例を漁ったものの解決できませんでした。
よって、直接prefetchQuery()
を記述しつつ、中身のリクエスト処理やリクエスト、レスポンスの型はOrvalで生成されたものを活用するようにして実装しました。
Orvalについて、別箇所でスタックオーバーフローが起きるバグに対するPRが上がっていたようで、もしかしたら関係あるのかも、?そこまで詳しくは調べられていません。
また、OrvalのCustomInstance
を設定すると、何か解決できるかもと考えています。こちらは追って試してみます。
Discussion