💊

[Tanstack Query & Orval] 自動生成したprefetchQueryが動かなくて色々工夫した

2024/12/07に公開

表題の通り、やりたいことを素直に実装しようとしたら上手くいかず、試行錯誤した話です。
私の認識誤りの可能性もありますので、解決策をご存知の方は是非コメントでご指摘お願いします。

背景

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のデフォルトはサーバーコンポーネントですし、上記を実現することでサーバーサイドで効率的にデータ取得を行うことが目的です。

https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr
https://tanstack.com/query/latest/docs/framework/react/guides/ssr

これを解説してくれている記事もすでにいくつかあったので参考にさせていただきました。ただ、これらではOrvalで自動生成されたコードの実装は取り上げられていなかったため、今回はそこにチャレンジした形です。

https://zenn.dev/tor_inc/articles/aa3e6f59016327
https://zenn.dev/noko_noko/articles/fd8a10c14de9c3

Orvalの設定

以下のように、Orvalにおける自動生成の設定を行っています。
ポイントは以下です。

  • output.clientreact-queryを指定
  • usePrefetch: trueとして、Tanstack QueryのprefetchQueryを使用したリクエストを出力する
orval.config.ts
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によるデータフェッチを利用できるわけです。

backend.ts
/**
 * 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を作成するようにします。

queryProvider.tsx
"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でクライアントコンポーネントを囲んでいます。

page.tsx
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を活用したデータフェッチができるということになります。

presenter.tsx
"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とすることで、適切にキャッシュされるようにします。

page.tsx
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()を使用してキャッシュからサーバーコンポーネント側でフェッチしておいたデータを呼び出します。

presenter.tsx
"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が上がっていたようで、もしかしたら関係あるのかも、?そこまで詳しくは調べられていません。

https://github.com/orval-labs/orval/issues/1347

また、OrvalのCustomInstanceを設定すると、何か解決できるかもと考えています。こちらは追って試してみます。

https://orval.dev/guides/custom-axios

Discussion