クエリパラメータ連携をON/OFF可能なページネーション機能【Next.js】

なぜ必要?

レンダリング的には150件表示したとしても決して重いわけではなく十分に表示する目的を果たせます.しかし,ユーザ側は無限にスクロールをしないといけなかったり,スクロールバーが縮んだりとユーザビリティ面では悪くなる結果になります.SNSでは無いのでスクロールしてネットサーフィンというわけでもなく,必要なトピックに直接ジャンプしてくる方が多いと思っています.そのため簡潔に表示して過大な情報量にならないように作り変えようと考えました.

実際に実装する

今回はPost型の配列が与えられ,PostCardコンポーネントで各個表示する前提のもと,コーディングします. 当ブログのシステムにも実装済みなため,気になる方はそちらも参照してください.

NextjsBlogWithGitPAT...Markdown blog u...https://github.com/isirmt/NextjsBlog...thumb

まずは全体のソースコードから,

'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'>(最大&nbsp;{maxPage}ページ)</span>
      </div>
    </div>
  );
}

p変数の割当の際にuseRoutingで分岐を行っています.これが表題の「クエリパラメータ連携をON/OFF」です.呼び出し元コンポーネントで/list?p=1のようなクエリパラメータを利用するかが設定できます(デフォルトはOFF).したがって,1つのコンポーネントで複数の貼付けも可能になるわけです.クエリパラメータのパラメータ名も変数化すれば細かい条件分岐したページをシェアや保持ができそうですね.

また,pageuseRoutingによって初期動作が異なるのでよく見る位置よりも下に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間の差異には気をつけたいところです.

コメント

自動更新
コメントはまだありません
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.