OpenAPIスキーマからTanStack Queryコードを生成してスマートにNext.js App Routerでdehydrate/hydrateする

Jun 8, 2024

App RouterでのTanStack Query

Next.js App RouterでTanStack Queryを使用する方法としてServer Componentでprefetchしてその後Hydrationを行う方法が公式ドキュメントでも紹介されています。

公式ドキュメントでのコードは以下のようになります。

// app/posts/page.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

ここでは以下の内容が行われています。

  • Server Component上でprefetchQuery()を使用してprefetchする
  • HydrationBoundary(Client Component)でHydrationを行う

そしてPostsコンポーネントは以下のように書かれています。

// app/posts/posts.jsx
'use client'

export default function Posts() {
  // This useQuery could just as well happen in some deeper
  // child to <Posts>, data will be available immediately either way
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix.
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}

PostsはhydrateしたデータをuseQuery()経由で取得するClient Componentとなります。 ここで重要なのはqueryKeyprefetchQuery()のものと同じでなければならないことです。 2回目に実行したuseQuery()queryKeyは一致しないため、ブラウザ上でしかデータフェッチされません。

このように、prefetch時とprefetchデータ利用時とで2度Queryを組み立てなければならないのと、それぞれqueryKeyを一致させなければなりません。 シンプルなアプリケーションの場合の多くは1つのAPIに対して複数のパターンのqueryKeyを扱うことはありません。その場合、上記例のようなpostsなどになるはずです。

OpenAPIスキーマからのTanStack Queryコード生成

さて本題のOpenAPIスキーマからTanStack Queryコードを直接生成してどうNext.js App Routerに組み込むのかを説明します。

OpenAPIからTanStack Queryコードを生成するライブラリはいくつか存在しますが、特に設定不要で始められるのと、筆者がメンテしているというのもありOpenAPI React Query Codegenを使用します。

例えば以下のpetstore.yamlというスキーマファイルがあるとします。

openapi: "3.0.0"
info:
  version: 1.0.0
  title: Swagger Petstore
  description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification
  termsOfService: http://swagger.io/terms/
  contact:
    name: Swagger API Team
    email: apiteam@swagger.io
    url: http://swagger.io
  license:
    name: Apache 2.0
    url: https://www.apache.org/licenses/LICENSE-2.0.html
servers:
  - url: http://petstore.swagger.io/api
paths:
  /pets:
    get:
      description: |
        Returns all pets from the system that the user has access to
        Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia.

        Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien.
      operationId: findPets
      parameters:
        - name: tags
          in: query
          description: tags to filter by
          required: false
          style: form
          schema:
            type: array
            items:
              type: string
        - name: limit
          in: query
          description: maximum number of results to return
          required: false
          schema:
            type: integer
            format: int32
      responses:
        '200':
          description: pet response
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      description: Creates a new pet in the store. Duplicates are allowed
      operationId: addPet
      requestBody:
        description: Pet to add to the store
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewPet'
      responses:
        '200':
          description: pet response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /not-defined:
    get:
      deprecated: true
      description: This path is not fully defined.
      responses:
        default:
          description: unexpected error
    post:
      deprecated: true
      description: This path is not defined at all.
      responses:
        default:
          description: unexpected error
  /pets/{id}:
    get:
      description: Returns a user based on a single ID, if the user does not have access to the pet
      operationId: find pet by id
      parameters:
        - name: id
          in: path
          description: ID of pet to fetch
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: pet response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      description: deletes a single pet based on the ID supplied
      operationId: deletePet
      parameters:
        - name: id
          in: path
          description: ID of pet to delete
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '204':
          description: pet deleted
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    Pet:
      allOf:
        - $ref: '#/components/schemas/NewPet'
        - type: object
          required:
          - id
          properties:
            id:
              type: integer
              format: int64

    NewPet:
      type: object
      required:
        - name  
      properties:
        name:
          type: string
        tag:
          type: string    

    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: integer
          format: int32
        message:
          type: string

その場合OpenAPI React Query Codegenを使う場合は以下コマンドを実行します。

npx @7nohe/openapi-react-query-codegen -i ./petstore.yaml

実行後にはopenapiというディレクトリができ、その配下にrequestsqueriesというディレクトリが作成されます。 詳しい説明は省きますがそれぞれ以下のようなファイル群となります。

  • requests: ピュアなTSクライアントコード(ReactやTanStackに関わらない)
  • queries: TanStack Queryに関するコード

この生成されたコードを先ほどのTanStackの公式ドキュメントのようにApp Routerに組み込むと以下のようになります。

// app/pets/page.tsx

import { prefetchUseDefaultServiceFindPets } from "../../openapi/queries/prefetch";
import {
  HydrationBoundary,
  QueryClient,
  dehydrate,
} from "@tanstack/react-query";
import Pets from "./pets";

export default async function PetsPage() {
  const queryClient = new QueryClient();

  await prefetchUseDefaultServiceFindPets(queryClient);

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Pets />
    </HydrationBoundary>
  );
}

Petsコンポーネント側はどうなるかというと、

// app/pets/pets.tsx
"use client";

import { useDefaultServiceFindPets } from "../../openapi/queries";

export default function Pets() {
  const { data } = useDefaultServiceFindPets();

  // ...
}

となります。 実際はパラメータとしてlimitやtagsを渡すことになると思いますが、queryKeyを考えて定数を用意して、queryFnへ渡すためのデータフェッチ関数をprefetchQuery()useQuery()へそれぞれimportしてセットする作業を省くことができました。 これによりqueryKeyがずれてうまくHydrationできなかったということが減ります。

実際に画面上で動かしてDevtoolsから確認してみてもQueryが一度しか呼ばれてないことが確認できますし、Networkタブで見てもServer Renderingされているのがわかると思います。

openapi-tanstack-query-nextjs-app-router-demo

以上でNext.js App RouterでのTanStack Query利用方法の説明と、OpenAPI React Query Codegenによる実装省力化の手法を簡単に紹介しました。 このライブラリはまだまだ育てている段階ですので、アイデアなどあればお待ちしております!