🐹

shadcn/uiとtanstackでサーバーサイドページネーションする方法

2024/08/17に公開

やりたいこと

shadcn/uiのData Tableで描画する際にサーバーサイドページネーションを行いたい。背景としては大量のデータが存在する項目を一覧表示したい場合に全てのデータをクライエントに渡すとデータの容量が重くなるためクライエントで指定した範囲のデータのみ取得してくるようにしたかったです。

注意点

私の理解が不十分な部分があり、manualPaginationの場合でもtableのstateを管理するためにTanStackのTable APIsが使用できたかもしれません。今後Table APIsで管理できた場合はそちらに書き直そうと思っています。
また簡単のため実際に動作させているコードから本題と逸れる部分は省いたためそのままでは動かない可能性があります。

Version情報

Next: 14.2.5
tanstack/react-table: 8.20.1

参考にした記事・リポジトリ

sadmann7さんが作成された下記の記事とリポジトリを参考にしました。デモ画面も用意されており、イメージが掴みやすかったです。
https://www.sadmn.com/blog/shadcn-table
https://github.com/sadmann7/shadcn-table/tree/e253b1728ed16cd7da48eb9a9f68e8d823ad08ba

やり方

/listというURLに表示するページを作成しようとしており、下記のような構成でファイルを追加しました。columns.tsxに関しては特に言及すべきところはなかったので説明を省きます。

app ━ list ━ page.tsx
           ┣ columns.tsx
           ┗ data-table.tsx

page.tsxの最終的なコードはこちらです。


import React  from 'react';

import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { DataBaseFields } from "@/types";

import { Family, columns } from "./columns";
import { DataTable } from "./data-table";


async function getData(pageIndex: number, pageSize: number): Promise<{data: Omit<Family, DataBaseFields>[], rowCount: number}> {
  try {
    const data = await prisma.family.findMany({
      select: {
        id: true,
        family_name: true,
        family_name_kana: true,
        tel: true,
        zip_code: true,
        abode: true,
        remarks: true,
      },
      skip: (pageIndex - 1) * pageSize,
      take: pageSize,
      where: {
        is_delete: false
      }
    });
    const rowCount = await prisma.family.count({
      where: {
        is_delete: false
      }
    });
    return {data, rowCount};
  } catch (error) {
    console.log(error);
    return { data: [], rowCount: 0 };
  } 
}

export interface ListPageProps {
  searchParams: {
    page: string | undefined
    per_page: string | undefined
  }
}

export default async function ProtectedPage({ searchParams }: ListPageProps) {
  const { page, per_page } = searchParams;

  const { data, rowCount } = await getData(Number(page),Number(per_page));

  return (
    <div className="flex items-center">
      <DataTable columns={columns} data={data} rowCount={rowCount}/>
    </div>
  );
}

page.tsxのファイルではクエリパラメータを使用し、pageとper_pageを設定するようにしており、それに合わせてprismaでデータと全てのデータの数を取得するようにしています。

data-table.tsxの最終的なコードはこちらです。

"use client";

import * as React from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
  ColumnDef,
  PaginationState,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";
import { z } from "zod";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
  rowCount: number
}

const searchParamsSchema = z.object({
  page: z.coerce.number().default(1),
  per_page: z.coerce.number().default(10),
});

const pageSizeOptions = [10, 20, 30, 40, 50];

export function DataTable<TData, TValue>({
  columns,
  data,
  rowCount,
}: DataTableProps<TData, TValue>) {

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    rowCount,
    // debugAll: true
  });

  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  // searchParamsSchema関数でバリデーションとデフォルト値の適用を行う
  const search = searchParamsSchema.parse(Object.fromEntries(searchParams));
  const page = search.page;
  const perPage = search.per_page;

  // Create query string
  const createQueryString = React.useCallback(
    (params: Record<string, string | number | null>) => {
      const newSearchParams = new URLSearchParams(searchParams?.toString());

      for (const [key, value] of Object.entries(params)) {
        if (value === null) {
          newSearchParams.delete(key);
        } else {
          newSearchParams.set(key, String(value));
        }
      }

      return newSearchParams.toString();
    },
    [searchParams]
  );

  const [{ pageIndex, pageSize }, setPagination] =
    React.useState<PaginationState>({
      pageIndex: page - 1,
      pageSize: perPage,
    });

  const hasPreviousPage = () => pageIndex > 0;
  const hasNextPage = () => rowCount > (pageIndex + 1) * pageSize;

  React.useEffect(() => {
    router.push(
      `${pathname}?${createQueryString({
        page: pageIndex + 1,
        per_page: pageSize,
      })}`,
      {
        scroll: false,
      }
    );
  }, [pageIndex, pageSize]);

  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                );
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
      <div className="flex items-center justify-end space-x-2 py-4">
        <Button
          variant="outline"
          size="sm"
          onClick={() => setPagination((prevState) => ({...prevState, pageIndex: prevState.pageIndex - 1}))}
          disabled={!hasPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => setPagination((prevState) => ({...prevState, pageIndex: prevState.pageIndex + 1}))}
          disabled={!hasNextPage()}
        >
          Next
        </Button>
        <select
          value={pageSize}
          onChange={(event) => setPagination(() => ({pageIndex: 0, pageSize: Number(event.target.value)}))}
        >
          {pageSizeOptions.map(pageSize => (
            <option key={pageSize} value={pageSize}>
              {pageSize}
            </option>
          ))}
        </select>
      </div>
    </div>
  );
}

data-table.tsxはclientコンポーネントとして設定をしています。そこでクエリパラメータを使用するためuseSearchParamsをimportします。

tableの定義はこちらを参考に下記のように書いています。

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    rowCount,
    // debugAll: true
  });

後続の部分はzodを使用してクエリパラメータのvalidationとデフォルト値を設定する処理、クエリパラメータを作成する処理を設定しています。参考にしたリポジトリのこちらの部分を抜き出して使用させていただきました。

  // searchParamsSchema関数でバリデーションとデフォルト値の適用を行う
  const search = searchParamsSchema.parse(Object.fromEntries(searchParams));
  const page = search.page;
  const perPage = search.per_page;


  const createQueryString = React.useCallback(
    (params: Record<string, string | number | null>) => {
      const newSearchParams = new URLSearchParams(searchParams?.toString());

      for (const [key, value] of Object.entries(params)) {
        if (value === null) {
          newSearchParams.delete(key);
        } else {
          newSearchParams.set(key, String(value));
        }
      }

      return newSearchParams.toString();
    },
    [searchParams]
  );

そしてpagenationをuseStateを使用して管理し、値が変化した際にレンダリングが起こるようにします。そしてuseEffectを使用してpageIndex,pageSizeが変化したことをトリガーに新たにクエリパラメータが設定されページ遷移するようにしました。その際スクロール位置が保存されるようにscroll: falseを設定しています。

const [{ pageIndex, pageSize }, setPagination] =
    React.useState<PaginationState>({
      pageIndex: page - 1,
      pageSize: perPage,
    });

  const hasPreviousPage = () => pageIndex > 0;
  const hasNextPage = () => rowCount > (pageIndex + 1) * pageSize;

  React.useEffect(() => {
    router.push(
      `${pathname}?${createQueryString({
        page: pageIndex + 1,
        per_page: pageSize,
      })}`,
      {
        scroll: false,
      }
    );
  }, [pageIndex, pageSize]);

終わりに

tableのとかgetCanPreviousPageとかgetCanNextPageとかを使って書けたらもっとスマートに書けるのかなと思いそちらも模索中です。また上記の場合/listにアクセスした際に一度空のデータを表示した後に/list?page=1&per_page=10のページが表示されるため、クエリパラメータが空の場合でも初回の描画から/list?page=1&per_page=10の描画ができるように改善したいと思っています。

Discussion