クエリパラメータ連携をON/OFF可能なページネーション機能【Next.js】
なぜ必要?
レンダリング的には150件表示したとしても決して重いわけではなく十分に表示する目的を果たせます.しかし,ユーザ側は無限にスクロールをしないといけなかったり,スクロールバーが縮んだりとユーザビリティ面では悪くなる結果になります.SNSでは無いのでスクロールしてネットサーフィンというわけでもなく,必要なトピックに直接ジャンプしてくる方が多いと思っています.そのため簡潔に表示して過大な情報量にならないように作り変えようと考えました.
実際に実装する
今回はPost
型の配列が与えられ,PostCard
コンポーネントで各個表示する前提のもと,コーディングします.
当ブログのシステムにも実装済みなため,気になる方はそちらも参照してください.
まずは全体のソースコードから,
'use client';
import { Post } from '@/static/postType';
import PostCard from './PostCard';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import React, { useEffect, useState } from 'react';
const MorePageSign = () => <div className='pointer-events-none block size-4 rounded-full bg-blue-200'></div>;
const PagingButton = ({ icon, title, func }: { icon: string; title?: string; func: () => void }) => (
<button
title={title}
className='group flex size-12 flex-col items-center justify-center rounded-full border border-blue-500 transition-colors hover:bg-blue-500'
onClick={func}
>
<span className={`${icon} size-8 bg-blue-500 transition-colors group-hover:bg-slate-50`} />
</button>
);
export default function PostPaging({
posts,
useIndex,
useRouting,
postsPerPage = 10,
linkingWidth = 2,
}: {
posts: Post[];
useIndex?: boolean;
useRouting?: boolean;
postsPerPage?: number;
linkingWidth?: number;
}) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const p = useRouting ? searchParams.get('p') : null;
const [page, setPage] = useState<number>(useRouting ? (p ? Number(p) : 1) : 1);
const startIndex = postsPerPage * (page - 1);
const maxPage = Math.ceil(posts.length / postsPerPage);
const linkingStartPage = Math.max(page - linkingWidth, 1);
const linkingEndPage = Math.min(page + linkingWidth, maxPage);
const linkingPages: number[] = [];
for (let i = linkingStartPage; i <= linkingEndPage; i++) {
linkingPages.push(i);
}
useEffect(() => {
if (page <= 0 || page > maxPage) {
if (useRouting) router.push(pathname);
setPage(1);
}
}, [maxPage, page, router, pathname, useRouting]);
const displayingPosts = posts.slice(startIndex, startIndex + postsPerPage);
return (
<div>
<div className='flex flex-col gap-y-3'>
{displayingPosts.map((post, i) => (
<React.Fragment key={i}>
{useIndex ? (
<div className='flex items-stretch gap-1'>
<div className='hidden w-10 shrink-0 select-none items-center justify-center overflow-hidden break-all rounded-sm bg-gray-100 px-0.5 text-center text-lg font-bold text-gray-700 dark:bg-slate-700 dark:text-slate-400 lg:flex'>
{startIndex + i + 1}
</div>
<div className='flex flex-grow'>
<PostCard post={post} />
</div>
</div>
) : (
<PostCard post={post} />
)}
</React.Fragment>
))}
</div>
<div
className={`${maxPage === 1 ? 'pointer-events-none opacity-50' : ''} mt-3 flex items-center justify-center gap-2`}
>
<PagingButton
title='前のページ'
icon='i-tabler-arrow-badge-left-filled'
func={() => {
const nextPage = page - 1 <= 0 ? maxPage : page - 1;
if (useRouting) router.push(`${pathname}?p=${nextPage}`);
setPage(nextPage);
}}
/>
<div className='flex gap-1'>
{linkingStartPage !== 1 ? <MorePageSign /> : <></>}
{linkingPages.map((item, i) => (
<button
key={i}
title={`${item}ページ目へ`}
className={`block size-4 rounded-full border border-blue-500 ${page === item ? 'pointer-events-none bg-blue-500' : 'bg-transparent'} transition-colors hover:bg-blue-500`}
onClick={() => {
const nextPage = item;
if (useRouting) router.push(`${pathname}?p=${nextPage}`);
setPage(nextPage);
}}
></button>
))}
{linkingEndPage !== maxPage ? <MorePageSign /> : <></>}
</div>
<PagingButton
title='次のページ'
icon='i-tabler-arrow-badge-right-filled'
func={() => {
const nextPage = page + 1 > maxPage ? 1 : page + 1;
if (useRouting) router.push(`${pathname}?p=${nextPage}`);
setPage(nextPage);
}}
/>
</div>
<div className='mt-2 select-none text-center text-gray-700'>
{page}ページ目 <span className='text-sm'>(最大 {maxPage}ページ)</span>
</div>
</div>
);
}
p
変数の割当の際にuseRouting
で分岐を行っています.これが表題の「クエリパラメータ連携をON/OFF」です.呼び出し元コンポーネントで/list?p=1
のようなクエリパラメータを利用するかが設定できます(デフォルトはOFF).したがって,1つのコンポーネントで複数の貼付けも可能になるわけです.クエリパラメータのパラメータ名も変数化すれば細かい条件分岐したページをシェアや保持ができそうですね.
また,page
はuseRouting
によって初期動作が異なるのでよく見る位置よりも下にuseStateの型を配置しています.この値を使ってstartIndex
のようなスライスするための変数が定義できます.
setPage
を関数コンポーネントの直下で呼んで宣言自体は上にしたら駄目なの?という疑問が出るかもしれませんが,問題点があります.「set関数でセットしてもその実行中の関数内では即座に反映されるわけではない」です.
これは,関数が1度return
まで実行され,非同期的に新しい状態として関数が実行されるイメージです.
個人的にはその後の処理の遅延が異なっていても,最終的には新しい状態として描画されることが保証されるのか?が気になるところではあります.クライアントサイドなのでそれは保証されているのでしょうか...?.
自分の周囲のページリスト
例えば7ページ目におり,全体が20ページの場合,全ての各ページへ飛ぶボタンを置くのはUI上良くありません.押し間違い等も発生しかねます.
そこで,以下のコードがコンポーネントの状態更新時毎に実行されます.
const linkingStartPage = Math.max(page - linkingWidth, 1);
const linkingEndPage = Math.min(page + linkingWidth, maxPage);
const linkingPages: number[] = [];
for (let i = linkingStartPage; i <= linkingEndPage; i++) {
linkingPages.push(i);
}
これは,ページリストの最小値・最大値を計算し,リストとして形作るコードです.linkingWidth
はコンポーネントの引数として設定されています.
描画について
<PagingButton>
や<MorePageSign>
はコンポーネントの外で定義された別のコンポーネントです.Tailwind CSSで文字列としてスタイル適用しているので変更の差異が発生しないようにまとめました.
また,useRouting=true
の場合はpathname
とページインデックスを組み合わせてボタンを押すたびにクエリパラメータを変更することで完成となります.
まとめ
シンプルなシステムでも遅延やバグ対策は大事ですね.気をつけないといけないこととして,クライアントサイドなのでハイドレーションワーニングが発生する可能性を憂慮して作業しなければなりません.「日付だとここからここまでを表示しています」などでは日付関数が呼ばれることになるのでOS間の差異には気をつけたいところです.