Prisma x react-query でページネーションを実装する
開発環境
- @prisma/client 4.6.1
- @tanstack/react-query 4.19.1
- @nestjs/cli 9.0.0
- next 13.0.5
前提
本記事のページネーションはカーソル方式です。
また今回は、記事一覧ページでの実装例としており、Postsモデルが記事に該当する想定です。
それと、本筋から外れてしまうので実装上の型定義など、削ぎ落としている部分がありますのでご了承ください。
本題
最近NestJSを触り始めたfo-jiです。
今回は結構な確率で実装する機会があるページネーションを実装してみます。
ページネーションはオフセット方式やページ番号方式など、さまざまですが今回はカーソル指定方式にしました。
はじめにサーバー側でページネーションに対応したサービスを作成します。
import { PrismaService } from '@/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Post } from '@prisma/client';
@Injectable()
export class PostsService {
constructor(private readonly prismaService: PrismaService) {}
getPosts(take: number, cursorId: string): Promise<Post[]> {
if (cursorId) {
return this.prismaService.post.findMany({
take,
skip: 1,
cursor: {
id: cursorId,
},
orderBy: {
id: 'asc',
},
});
} else {
return this.prismaService.post.findMany({
take,
orderBy: {
id: 'asc',
},
});
}
}
}
Prismaではtake, skip, cursorを利用してページネーションを実装していきます。
- take(取得件数)
- skip(1を設定することで、前回取得時のcursorIdにあたるデータをスキップ)
- cursor(カーソル位置、idに指定する値は一意である必要あり)
※最初のクエリにはcursorIdは存在しないので、条件分岐しています。cursorIdに空文字など入れるとデータを取得できなくなるので注意
今回はフロント側からtakeとcursorIdをリクエストするので引数で受け取っています。
次にフロント側でAPIまわりを実装します。
記事一覧を取得するusePostsというカスタムフックを作成し、ページネーションに対応できるようにtake, cursorIdをパラメータに含めるようにします。
また、queryKeyには一意でページを識別する値を設定する必要があるので、今回はnumというプロパティに 0, 1, 2 .. など数字の情報を持たせるようにしています。
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import type { Post } from 'prisma/prisma-client';
import type { Page } from '@/hooks/usePaginate';
export const getPosts = async ({
take,
cursorId,
}: Omit<Page, 'num'>): Promise<Post[]> => {
return await axios.get('/posts', {
params: {
take,
cursorId,
},
});
};
export const usePosts = ({ page, config }) => {
return useQuery({
queryKey: ['posts', page.num],
queryFn: async () => await getPosts(page),
keepPreviousData: true,
...config,
});
};
keepPreviousDataをtrueにすることで、新しいデータの取得ができるまで、以前のデータを表示し、取得が完了すると切り替わるというような挙動になります。
そしてページネーションのロジックをカスタムフックで実装します。
ページは「←prev」「next→」のような前後に1ページずつ移動するUIを想定しいます。
前後の動きはtoggleという関数にしています。
pageがサーバー側にリクエストするページの情報になります。
- num(ページ番号、はじめのページは0で固定)
- take(取得件数 ※今回固定にしてますが、動的に設定できるようにしても良さそうです)
- cursorId(一覧で取得したデータの配列の最後の要素を設定)
import { useCallback, useState } from 'react';
import type { Post } from '@prisma/client';
import type { AxiosRequestConfig } from 'axios';
export interface Page extends AxiosRequestConfig {
num: number;
take: number;
cursorId: string | undefined;
}
export type Model = Post;
export type PageActionType = 'next' | 'prev';
interface UsePagenateReturnType {
page: Page;
toggle: (type: PageActionType, data: Model[]) => void;
}
export const usePaginate = (): UsePagenateReturnType => {
const [page, setPage] = useState<Page>({
num: 0,
take: 3,
cursorId: undefined,
});
const toggle = useCallback((type: PageActionType, data: Model[]) => {
switch (type) {
case 'next':
setPage((prevState) => ({
num: prevState.num + 1,
take: Math.abs(prevState.take),
cursorId: data.at(-1)?.id, // <- (1)
}));
break;
case 'prev':
setPage((prevState) => ({
num: prevState.num - 1,
take: -Math.abs(prevState.take),
cursorId: data.at(0)?.id, // <- (2)
}));
break;
default: {
const _: never = type;
break;
}
}
}, []);
return { page, toggle };
};
Prismaの仕様ですが、takeの値をマイナスの値にすることでcursorIdを起点に前のデータを取得することができるので、これを利用して戻る動きを実現しています。
ここで個人的のはまってしまったポイントですが、cursorIdは基本的には一覧で表示している最後のデータを指定するので、次のページの場合は最後の要素を指定すればOK(1)。
ただ、前のページの場合、最後の要素は先頭のデータになります(2)。
これ、意外に見落としてしまいますのでご注意を。。
そして、記事一覧のコンポーネントを実装します。
import type { FC } from 'react';
import type { Post } from '@prisma/client';
import { Pagination } from '@/components/Pagination';
import { usePosts } from '../api/getPosts';
import { usePaginate } from '@/hooks/usePaginate';
export const PostsList: FC = () => {
const { page, toggle } = usePaginate();
const { data } = usePosts({ page });
return (
<>
{data?.length === 0 ? (
<div>記事がありません</div>
) : (
<ul className="flex flex-col space-y-3">
{data?.map((post) => (
<li key={post.id} className="w-full bg-white p-4 shadow-sm">
<div>
<span className="text-xs font-semibold">{post.title}</span>
</div>
<div>{post.createdAt.toString()}</div>
<div>{post.text}</div>
</li>
))}
</ul>
)}
<div className="p-5 text-center">
{data != null && (
<Pagination<Post> data={data} page={page} toggle={toggle} />
)}
</div>
</>
);
};
最後に、ページネーションのコンポーネントを実装します。
import { Button } from '@/components/Elements';
import type { Model, Page, PageActionType } from '@/hooks/usePaginate';
interface PaginationProps<TModel> {
data: TModel[];
page: Page;
toggle: (type: PageActionType, data: TModel[]) => void;
}
export const Pagination = <TModel extends Model>({
data,
page,
toggle,
}: PaginationProps<TModel>): JSX.Element => (
<div className="mx-auto max-w-2xl">
<nav>
<ul className="inline-flex -space-x-px">
{page.num !== 0 && (
<li>
<Button onClick={() => toggle('prev', data)}>←prev</Button>
</li>
)}
{data.length === Math.abs(page.take) && (
<li>
<Button onClick={() => toggle('next', data)}>next→</Button>
</li>
)}
</ul>
</nav>
</div>
);
さいごに
サーバーサイド(NestJS)の実装は非常にシンプルにできました。
今回カーソル方式で実装していますが、リクエストしないと次のページがあるかどうかが分からないので、この辺りは工夫が必要だなと思います。
また、何ページ目を表示するみたいなよくあるページ移動ができないのも欠点ではあるので要件に合わせて採用するか考えたいですね。