🥝

SWRとSupabaseを使ったNext.jsでのページネーション

2022/11/21に公開

この記事について

タイトルにある通りですが、SWR+Supabaseでのページネーションを実装していきます。
https://swr.vercel.app/ja

https://app.supabase.com/

SWR

今回は、useSWRuseSWRInfiniteを使用した2パターンをご用意してます。

useSWRInfiniteは、ページネーション用にSWRの公式が用意した専用APIのことで、
通常のページネーションでも使用できるけど、無限ローディングを実装したい方にはとくにおすすめです。
https://swr.vercel.app/ja/docs/pagination

Supabase

Supabaseの方では、range()を使用しています。
第一引数にはじまりの行、第二引数にさいごの行を指定して使います。
range(1,5)で1~5行目、range(6,10)で6~10行目、range(11,15)で11~15行目といった感じ。

実装

まずは完成したページと、コードを載せておきます。

useSWRを使用したパターン

https://youtu.be/wZOOT2rV4tw

src/pages/index.tsx
import { supabase } from "src/libs/supabase";
import { useState } from "react";
import useSWR from "swr";
import Image from "next/image";

// useSWRの第一引数のリストを受け取る
// Supabaseに用意されているrange()を使用する
// range(start, end)は、はじまりの行数と終わりの行数を指定できる
// これを使って、1~3行目,4~6行目,7~9行目とページごとに取得する行番号を変える
const fetcher = (key: string, page: number, limit: number) => {
  const start = limit * (page - 1);
  const end = start + limit - 1;
  const { data } = await supabase.from(key).select("*").range(start, end);
  return data;
};

const Index = () => {
  const limit = 3; // ページ毎に取得するデータ数
  const [page, setPage] = useState(1); // 現在のページ番号
  
  // useSWRの第一引数をリスト型に。リスト全体で1つのキャッシュキーとされる。
  // 第一引数をリスト型にすると、fetcherに順番に代入される。
  const { data: customers, error } = useSWR(["customers", page, limit], fetcher);
  
  return (
    <div className="fixed top-0 bottom-0 inset-0 bg-gray-200 px-20 flex items-center justify-center">
      <div>
         <div className="p-20 rounded-t-md bg-white space-y-4">
          {customers.map((customer) => {
            return (
              <div
                key={customer.id}
                className="bg-white px-4 border-2 rounded-md"
              >
                <div className="grid grid-cols-[auto,1fr,auto,50px] gap-3 py-3 ">
                  <div className="flex items-center justify-center">
                    {customer.pictureurl ? (
                      <Image
                        width={50}
                        height={50}
                        src={customer.pictureurl}
                        alt={""}
                        objectFit="cover"
                        className="flex items-center rounded-full"
                      />
                    ) : (
                      <div className="h-12 w-12 animate-pulse rounded-full bg-gray-200"></div>
                    )}
                  </div>

                  <div className="flex flex-col items-start gap-3">
                    <div className="flex gap-2">
                      <p className="text-lg font-semibold">
                        {customer.name}
                        <span className="text-sm"></span>
                      </p>
                    </div>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
        <div className="flex w-full flex-col bg-white rounded-b-md border-t">
          <div>
            <div className="p-4 flex items-center justify-between">
              <p>{`${(page - 1) * limit + 1} - ${page * limit} / ${total}`}</p>
              <div className="flex gap-1">
                <button
                  className="flex items-center justify-center border-gray-200 px-4 py-2 rounded-md border hover:border-blue-400"
                  onClick={() => {
                    setPage(page - 1);
                  }}
                >
                  前へ
                </button>

                <button
                  className="flex items-center justify-center border-gray-200 px-4 py-2 rounded-md border hover:border-blue-400"
                  onClick={() => {
                    setPage(page + 1);
                  }}
                >
                  次へ
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Index;

useSWRInfiniteを使用したパターン

https://youtu.be/iTtExhkMpU8

src/pages/index.tsx
import { supabase } from "app/libs/supabase";
import { Database } from "app/types/supabase";
import { useState } from "react";
import useSWRInfinite from "swr/infinite";
import Image from "next/image";

type Customer = Database["public"]["Tables"]["customers"]["Row"];

const fetcher = async (key: string, page: number, limit: number) => {
  const start = limit * page;
  const end = start + limit - 1;
  const { data } = await supabase.from(key).select("*").range(start, end);
  return data as Customer[];
};

export const Page = () => {
  const [limit, setLimit] = useState(2);
  const getKey = (pageIndex: number, previousPageData: Customer[]) => {
    if (previousPageData && !previousPageData.length) return null; // 最後に到達した
    return ["customers", pageIndex, limit]; // SWR キー
  };
  const { data: customers, size, setSize } = useSWRInfinite(getKey, fetcher);

  return (
    <div className="fixed top-0 bottom-0 inset-0 bg-gray-200 px-20 flex items-center justify-center">
      <div>
        <div className="p-20 rounded-t-md bg-white space-y-4 h-[800px] w-[400px]">
          {customers?.map((items) => {
            return items.map((customer) => {
              return (
                <div
                  key={customer.lineid}
                  className="bg-white px-4 border-2 rounded-md"
                >
                  <div className="grid grid-cols-[auto,1fr,auto,50px] gap-3 py-3 ">
                    <div className="flex items-center justify-center">
                      {customer.pictureurl ? (
                        <Image
                          width={50}
                          height={50}
                          src={customer.pictureurl}
                          alt={""}
                          objectFit="cover"
                          className="flex items-center rounded-full"
                        />
                      ) : (
                        <div className="h-12 w-12 animate-pulse rounded-full bg-gray-200"></div>
                      )}
                    </div>

                    <div className="flex flex-col items-start gap-3">
                      <div className="flex gap-2">
                        <p className="text-lg font-semibold">
                          {customer.displayname}{" "}
                          <span className="text-sm"></span>
                        </p>
                      </div>
                    </div>
                  </div>
                </div>
              );
            });
          })}
        </div>
        <div className="flex w-full flex-col bg-white rounded-b-md border-t">
          <div className="p-4 flex items-center justify-center">
            <button
              className="flex items-center justify-center border-gray-200 px-4 py-2 rounded-md border hover:border-blue-400"
              onClick={() => {
                setSize(size + 1);
              }}
            >
              さらに読み込む
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Page;

useSWRInfiniteのポイント

getKey()でキャッシュキーを動的に取得

getKey()は、名前の通りSWRのキャッシュキーを返す関数のことで、ページ番号(初期値:0)と一つ前に取得したデータ(Article[]など)を引数に取ります。
一つ前に取得したデータは、Firestore などのカーソル式ページネーションで活用できます。
戻り値は、通常のuseSWRのキャッシュキー(第一引数)同様、fetcherの引数に代入して使用できます。
また、「次のデータを取得するか?」 という判断もgetKey()の中で行ないます。
if (previousPageData && !previousPageData.length) return nullの部分

「さらに読み込む」がたったの1行

<button onClick={() => setSize(size + 1)}>さらに読み込む</button>

fetchしてきたデータの型

データは、複数のAPIレスポンスの配列になります。

公式より引用
// `data` はこのようになります
[
  [
    { name: 'Alice', ... },
    { name: 'Bob', ... },
    { name: 'Cathy', ... },
    ...
  ],
  [
    { name: 'John', ... },
    { name: 'Paul', ... },
    { name: 'George', ... },
    ...
  ],
  ...
]

さいごに

以上です。
今回、仕事でSWRを使ったページネーションを実装する機会がありましたので、議事録ついでに執筆しました。

これからも、こういった技術ブログ的な記事を増やしていこうと思います。

Discussion