Zod OpenAPI Honoからカスタムフックを生成して楽に無限スクロールを実現する

Aug 8, 2024

OpenAPI React Query CodegenのuseInfiniteQueryサポート

私がメンテしているライブラリOpenAPI React Query Codegenのv1.5.0がリリースされ、useInfiniteQueryに対応しました。

v1.5.0からは--pageParamオプションと--nextPageParamオプションが追加されました。

OpenAPIスキーマ上で以下条件を満たしているAPIに対してはカスタムuseInfiniteQueryが生成されるという機能です。

  • pageParamで指定した値(デフォルトはpage)がクエリパラメータとして指定可能
  • nextPageParamで指定した値(デフォルトはnextPage)がレスポンスに含まれる

この機能をどう使うかの例としてZod Hono OpenAPIでページネーションに対応した一覧データを返すAPI処理を作成、そこからOpenAPI React Query Codegenを使って簡単に無限スクロールを実現する方法を紹介します。

Zod OpenAPI Hono

Honoのプロジェクト作成済みである前提で進めます。 まずはZod OpenAPIとSwagger UIのHonoミドルウェアをインストールします。

pnpm add zod @hono/zod-openapi @hono/swagger-ui

次にroute.tsというファイルを用意して、スキーマとルーティングを定義します。

import { createRoute } from "@hono/zod-openapi";
import { z } from "@hono/zod-openapi";

export const PetsParamSchema = z.object({
  limit: z
    .string()
    .optional()
    .openapi({
      param: {
        name: "limit",
        in: "query",
      },
      example: "10",
      type: "integer",
    }),
  page: z
    .string()
    .optional()
    .openapi({
      param: {
        name: "page",
        in: "query",
      },
      example: "1",
      type: "integer",
    }),
});

export const PetSchema = z
  .object({
    name: z.string(),
    id: z.number(),
  })
  .openapi("Pet");

export const PetsSchema = z.object({
  pets: z.array(PetSchema),
  nextPage: z.number(),
});

export const petsRoute = createRoute({
  method: "get",
  path: "/pets",
  request: {
    query: PetsParamSchema,
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: PetsSchema,
        },
      },
      description: "Returns a list of pets",
    },
  },
});

そしてindex.tsでroute.tsで定義したAPIのロジックを書きます。 今回レスポンスデータは配列を作成して返すだけにしています。

import { OpenAPIHono } from "@hono/zod-openapi";
import { petsRoute } from "./route";
import { swaggerUI } from "@hono/swagger-ui";
import { cors } from "hono/cors";

const app = new OpenAPIHono();

app.use("/*", cors());

app.openapi(petsRoute, (c) => {
  const { limit = 10, page = 1 } = c.req.valid("query");

  const pets = Array.from({ length: Number(limit) }).map((_, i) => ({
    name: `Pet ${i + 1 + Number(page) * Number(limit)}`,
    id: i + 1 + Number(page) * Number(limit),
  }));

  return c.json({
    pets,
    nextPage: Number(page) + 1,
  });
});

app.doc31("/doc", {
  openapi: "3.1.0",
  info: {
    version: "1.0.0",
    title: "My API",
  },
});

app.get("/ui", swaggerUI({ url: "/doc" }));

export default app;

ポイントはクエリパラメータとしてpageを受け取り、レスポンスにnextPageを含めることです。 これをすることで次に説明するカスタムuseInfiniteQueryの生成ができるようになります。

ここでHonoを開発サーバーを起動すると、http://localhost:8787/docでスキーマ、http://localhost:8787/uiでSwagger UIが確認できるはずです。

カスタムuseInfiniteQueryの生成

Honoで生成されたOpenAPIスキーマを元にクライアント側でのデータ取得用のカスタムuseInfiniteQueryコードを生成します。

以下コマンドを実行します。

npx @7nohe/openapi-react-query-codegen -i http://localhost:8787/doc --base http://localhost:8787

Note

pageParamとnextPageParamそれぞれデフォルト値で良いのでオプションの指定は今回必要ありません。

実行後はopenapi/queries/infiniteQueries.tsというファイルが出力されているはずです。

中身を確認すると、

// generated with @7nohe/openapi-react-query-codegen@1.5.1 

import { InfiniteData, UseInfiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query";
import { DefaultService } from "../requests/services.gen";
import * as Common from "./common";
/**
* @param data The data for the request.
* @param data.limit
* @param data.page
* @returns unknown Returns a list of pets
* @throws ApiError
*/
export const useDefaultServiceGetPetsInfinite = <TData = InfiniteData<Common.DefaultServiceGetPetsDefaultResponse>, TError = unknown, TQueryKey extends Array<unknown> = unknown[]>({ limit }: {
  limit?: number;
} = {}, queryKey?: TQueryKey, options?: Omit<UseInfiniteQueryOptions<TData, TError>, "queryKey" | "queryFn">) => useInfiniteQuery({ queryKey: Common.UseDefaultServiceGetPetsKeyFn({ limit }, queryKey), queryFn: ({ pageParam }) => DefaultService.getPets({ limit, page: pageParam as number }) as TData, initialPageParam: 1, getNextPageParam: response => (response as { nextPage: number }).nextPage, ...options });

useInfiniteQueryをラップしたgetPets()(Honoで用意したAPIを呼び出すメソッド)用のカスタムフックが生成されました。

無限スクロール

あとは生成されたカスタムフックを使用して、無限スクロールのUIを構築するのみです。 すでにReactプロジェクトがある前提で進めます。まずは必要なパッケージをインストールします。

pnpm add @tanstack/react-query react-intersection-observer

無限スクロールはTanStack Query公式ドキュメントのサンプルコードを少し変えて以下のようになりました。

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { useDefaultServiceGetPetsInfinite } from "../openapi/queries/infiniteQueries";
import React, { useEffect } from "react";

const queryClient = new QueryClient();

function Root() {
  const { ref, inView } = useInView();

  const {
    data,
    fetchNextPage,
    status,
    hasNextPage,
    isFetchingNextPage,
    isFetching,
  } = useDefaultServiceGetPetsInfinite({
    limit: 10,
  });

  useEffect(() => {
    if (inView) {
      fetchNextPage();
    }
  }, [inView, fetchNextPage]);

  return (
    <div>
      <h1>Infinite Loading</h1>
      {status === "pending" ? (
        <p>読み込み中...</p>
      ) : status === "error" ? (
        <span>読み込みエラー</span>
      ) : (
        <>
          {data.pages.map((page) => (
            <React.Fragment key={page.nextPage}>
              {page.pets?.map((pet) => (
                <p
                  style={{
                    border: "1px solid gray",
                    borderRadius: "5px",
                    padding: "10rem 1rem",
                    background: `hsla(${pet.id * 30}, 60%, 80%, 1)`,
                  }}
                  key={pet.id}
                >
                  {pet.name}
                </p>
              ))}
            </React.Fragment>
          ))}
          <div>
            <button
              ref={ref}
              onClick={() => fetchNextPage()}
              disabled={!hasNextPage || isFetchingNextPage}
            >
              {isFetchingNextPage
                ? "読み込み中..."
                : hasNextPage
                ? "さらに読み込む"
                : "読み込み完了"}
            </button>
          </div>
          <div>
            {isFetching && !isFetchingNextPage
              ? "Background Updating..."
              : null}
          </div>
        </>
      )}
    </div>
  );
}

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Root />
    </QueryClientProvider>
  );
}

export default App;

OpenAPIスキーマから生成されたカスタムフックのuseDefaultServiceGetPetsInfinite()を呼び出すだけで、一覧データのフェッチとステートへの格納、次ページ分のデータフェッチ処理を書かずに無限スクロールが実現できました。

Infinite Scroll Demo

実際に動かしてDeveloper ToolsのNetworkタブを確認してもページネーションがうまくできていますね。

ソースコード

実際作成したソースコードは公開していますので、よろしければ参考にしてください。