useSWRPages Pagination

前回、SWRでデータフェッチを取り上げました。このライブラリはページネーションも実現できるらしいので、今回はこれに挑戦してみます。

⚠利用するAPIはDocsに記載されていないため、今後仕様が変わることがあるかもしれません。

useSWRPages

SWRのuseSWRPages Hooksが今回の主役です。

const { pages, isLoading, isReachingEnd, loadMore } = useSWRPages(
    pageKey,
    pageFn,
    SWRToOffset,
    deps
);

各引数はクセがあるため、1つずつ解説します。

pageKey

ページキー。ページネーションを区別する文字列です。キャッシュでも使われます。

pageFn

ページコンポーネント。ページレンダリングに使われるReactコンポーネントを返す関数です。引数ではoffsetwithSWRを受け取れます。一般的にwithSWR内では、useSWRを使いデータフェッチを行うことになります。

({ offset, withSWR }) => {
	const { data } = withSWR(
    useSWR("/https://my-api/page=" + offset, fetcher)
  );
  if (!data) return null;

  const { results } = data;

	return results.map(result => (
  	<MyComponent key={result.name} />
  ));
}

useSWRで問い合わせを行うURLは、遷移先のページを取得できるような構造をとる必要があります。上記のoffsetは単なるページ番号を想定していますが、offsetに次に問い合わせを行うURLを直接埋め込む仕組みを作っても問題ありません。尚、この関数内ではデータが届いていない場合のフォールバックが必要です。

SWRToOffset

SWRオフセット。現在のページから次のページのオフセット(範囲)を返す必要があります。ページコンポーネントがレンダリングされた後に呼ばれるコールバックです。この関数は、SWRindexを受け取ります。

  • SWR – 今回のAPI取得結果が入るオブジェクト。SWR.dataに実データが入っています。
  • index – この関数が呼ばれた回数。ページコンポーネントが新たにレンダリングされるたびにインクリメントされる。

(SWR, index) => {
    if (SWR.data && SWR.data.length === 0) return null;
    return index + 1;
}

次のページがない場合はnullを返し、それ以外はindexをインクリメントしています。戻り値は次回ページコンポーネント関数が呼ばれるときのoffsetに格納されます。

deps

ページコンポーネントの深さを指定できます。特に不要なら空の配列を指定すればいいようです。

戻り値

戻り値は4つあります。

  • pages – ページ要素が含まれたオブジェクト
  • isLoadingMore – データ取得中を判別する論理値
  • isReachingEnd – 取得するものがなくなったかを判別する論理値
  • loadMore – 次のページを取得をリクエストするトリガー関数

これでuseSWRPagesはマスターです。とは言え、仕様を読むだけでは掴みきれない部分もあるため、例の如くサンプルを作ってみました。以降で要点を解説していきます。

Demo App

前回も利用したMTG APIを使用して、カードの一覧を表示するページを作成します。最初にカードは10枚表示されますが、ボタンを押すと次の10枚が取得され画面に追加されます。アプリケーションのベースには、Next.jsを使用しています。

コードはCodeSandboxに置いたので、必要に応じて補完してください。

以下はメインのApp.jsです。今回は利用していませんが、Next 9で搭載されたAPIルートにバックエンドを作り、そこにリクエストを投げるのも便利だと思います。

import useSWR, { useSWRPages } from "swr";
import fetcher from "../lib/fetcher";
import Card from "../components/Card";
import Button from "../components/Button";

const baseURL = "https://api.magicthegathering.io/v1/cards?page=";
const options = "&pageSize=10";

function Home() {
  const { pages, isLoadingMore, loadMore } = useSWRPages(
    "mtg-card",
    ({ offset, withSWR }) => {
      console.log("firstoffset:", offset);
      const { data } = withSWR(
        useSWR(baseURL + (offset || 1) + options, fetcher)
      );
      if (!data)
        return (
          <>
            <img src="/load.gif" alt="" />
            <style jsx>{`
              img {
                position: fixed;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
              }
            `}</style>
          </>
        );
      const { cards } = data;
      return <Card cards={cards} />;
    },
    (SWR, index) => {
      console.log("afterOffset(index):", index);
      if (SWR.data && SWR.data.length === 0) return null;
      return index + 2;
    },
    []
  );

  return (
    <>
      <h1>useSWRPages</h1>
      <div>{pages}</div>
      <Button isLoadingMore={isLoadingMore} loadMore={loadMore} />
      <style jsx>{`
        div {
          display: flex;
          flex-wrap: wrap;
        }
      `}</style>
    </>
  );
}

export default Home;

APIはhttps://api.magicthegathering.io/v1/cards?page=[pageNumber]&pageSize=10という形式でリクエストします。[pageNumber]は、ページネーション毎に変えます。useSWRでは、offsetnullならば埋め込む値を1にしています。これで初回リクエストで1ページ目のリクエストが作成できます。以降のページは、このoffsetをSWRオフセットで更新していくことになります。

デモでは順当にページが進んだときのSWRオフセットの加算値をindex + 2としています。これは一般的なAPIだとindex + 1が妥当でしょうが、MTG APIはpageSizeを固定したときのpage=0page=1の結果が同じになるため、0ページの次が1ページにならないように加算値を2にしています。要はoffsetindexに差があるためです。

以下はコード内に定義してあるconsole.logの出力です(afteroffsetはindexです)。

初回ロード
- firstoffset: null
- afteroffset: 未表示

初回レンダリング完了後
- firstoffset: null
- afteroffset: 0

Load Moreボタンを1クリック
- firstoffset: 2
- afteroffset: 未表示

Load Moreボタンを1クリックした後のレンダリング完了後
- firstoffset: 2
- affteroffset 1

offsetindexで差が-1あり、実際に次のページに必要なoffsetindex+2というわけです。これはあくまでMTG APIの仕様なので特に頭に入れる必要はありませんが、このコンソール出力を見ればuseSWRPages内の各関数がどのタイミングで呼ばれているかが理解できます。

コードの解説に戻りますが、あとはpagesに入った結果をレンダリングするだけです。また、追加の読み込みを行うボタンを配置します。

const Button = ({ isLoadingMore, loadMore }) => (
  <>
    <button disabled={isLoadingMore} onClick={loadMore}>
      Load More
    </button>
    <style jsx>{`
      button {
        display: block;
        width: 100%;
        padding: 1rem;
        cursor: pointer;
        margin-bottom: 1rem;
      }
    `}</style>
  </>
);

export default Button;

isLoadingMoretrueのときは読み込み中なのでボタンを無効化します。更に読み込みを行いたいときは、ボタンクリック…つまりpropsから取得したloadMoreをトリガーさせます。

表示するカードは、Cardコンポーネントで切り分けてあります。

const Card = ({ cards }) => {
  return (
    <>
      {cards.map(card => (
        <article key={card.id}>
          <h3>{card.name}</h3>
          <img
            src={card.imageUrl ? card.imageUrl : "/404.png"}
            alt={card.name}
            width="185"
            height="258"
          />
          <p>{card.originalText ? card.originalText : "no text"}</p>
        </article>
      ))}
      <style jsx>{`
        article {
          width: 220px;
          margin: 1rem 0.5rem;
          border: 1px dotted #ccc;
          padding: 1rem;
        }
        img {
          display: block;
          margin: 0 auto;
          background: #ddd;
        }
      `}</style>
    </>
  );
};

export default Card;

これでページネーションに必要なすべてのパーツが揃いました。本題と関係のないファイルについては、CodeSandbox内のコードを参照してください。実行するとアプリが動きます。