yourmystar tech blog
著者: nishino 公開日:

Blitz.js による無限リストの作り方(2)

この記事はユアマイスター アドベントカレンダー 2022の 14 日目の記事です

前回のおさらい

前回(アドベントカレンダー 2022 の 7 日目)の記事で、Blitz.js による無限リストを作りました。今回は、この無限リストを未来方向ではなく、過去方向にもリストを読み込むボタンを作ります。

Blitz.js とは何か?については前回の記事に記載していますので、そちらを参照してください。

この記事でできること

初期表示時に、今日から始まる 10 件のデータを表示し、「以後のデータを読み込む」ボタンをクリックすると「未来」のスケジュールが表示され、「以前のデータを読み込む」ボタンをクリックすると「過去」のスケジュールが表示されるリストを作ります。

無限リスト(未来および過去方向)の考え方

普段、私たちが SNS や Web サービスで、未来および過去方向へ進むリストについて、目にする機会がないので、軽く未来および過去方向へのリスト遷移について整理しておきます。

少なくとも私は、今のデータから未来および過去の両方に 10「件」分のデータを取得する UI を見たことがありません。(見たことがあるという方は、コメント欄にて教えてください。)

パッと思いつくのは、Google カレンダーなどのカレンダーアプリですが、

-01_gcal.png

これは初めに今月(2022 年 12 月)のデータを表示し、前の月(2022 年 11 月)のデータを表示する場合は、2022 年 11 月を条件にして再検索して表示しています。

これは、検索対象の年月がわかっていればできますが、今日のデータから 10 件分古いデータを取得する場合は、前の月という検索条件では取得できません。

Twitter や Facebook も、基本的には未来の情報はないので、現在から過去への一方向の検索で済みます。

GCP のログエクスプローラは、それっぽい画面ですが、

-02_gcp.png

このログエクスプローラも今のデータから 10 件といった検索ではなく、「1 時間延長」と記載されている通り、検索条件の範囲を現在の条件から 1 時間過去(または未来)に変更して再検索しているに過ぎません。

ではどうやったら、今のデータから 10 件分古いデータを取得するのでしょうか?

簡単です。

全データを日付順にソートし、今のデータが何件目かを確認した上で、その件数分をスキップして初期表示を行い、前の 10 件を表示する場合は、スキップする件数を 10 件減らして再検索を行います。

SQL でいう OFFSET を使います。

参考:Prisma.js の Offset Pagination

Pagination (Reference)

無限リスト(未来および過去方向)の実装

それでは、無限リスト(過去方向)を実装していきます。

本日データを最初に表示するためにスキップが必要な件数を取得するクエリの作成

以下のファイルを追加します。

  • src/schedules/queries/getScheduleSkipCount.tsx
import { resolver } from '@blitzjs/rpc'
import db, { Prisma } from 'db'

interface GetScheduleSkipCountInput extends Pick<Prisma.ScheduleFindManyArgs, 'where'> {}

export default resolver.pipe(resolver.authorize(), async ({ where }: GetScheduleSkipCountInput) => {
  const count = await db.schedule.count({
    where,
  })
  return count
})

このクエリは、引数のパラメータ( where)を条件に、カウント数を返すクエリです。リストページでこの getScheduleSkipCount を呼び出して、取得したカウント数分スキップし、本日以降のデータを初期表示します。

次に、以下のページファイルを修正します。

  • src/pages/schedules/index.tsx
diff --git a/src/pages/schedules/index.tsx b/src/pages/schedules/index.tsx
index 213cd97..acb561c 100644
--- a/src/pages/schedules/index.tsx
+++ b/src/pages/schedules/index.tsx
@@ -2,21 +2,29 @@ import { Suspense } from "react"
 import { Routes } from "@blitzjs/next"
 import Head from "next/head"
 import Link from "next/link"
-import { useInfiniteQuery } from "@blitzjs/rpc"
+import { useQuery, useInfiniteQuery } from "@blitzjs/rpc"
 import { useRouter } from "next/router"
 import Layout from "src/core/layouts/Layout"
+import getScheduleSkipCount from "src/schedules/queries/getScheduleSkipCount"
 import getSchedules from "src/schedules/queries/getSchedules"
-import { format } from "date-fns"
+import { format, startOfToday } from "date-fns"

 const ITEMS_PER_PAGE = 2

 export const SchedulesList = () => {
   const router = useRouter()
   const page = Number(router.query.page) || 0
+  const [skipCount] = useQuery(getScheduleSkipCount, {
+    where: {
+      date: {
+        lt: startOfToday(),
+      },
+    },
+  })
   const [schedulePages, { isFetching, isFetchingNextPage, fetchNextPage, hasNextPage }] =
     useInfiniteQuery(
       getSchedules,
-      (page = { take: ITEMS_PER_PAGE, skip: 0, orderBy: { date: "asc" } }) => page,
+      (page = { take: ITEMS_PER_PAGE, skip: skipCount, orderBy: { date: "asc" } }) => page,
       {
         getNextPageParam: (lastPage) => lastPage.nextPage,
       }

先ほど作成した getScheduleSkipCount クエリを import し、 useQuery のパラメータに指定します。where 句には「今日より前( lt : less than startOfToday )」を指定します。

startOfToday は、date-fns のメソッドで今日の 0 時を表す Date 型を返します。

date-fns のリファレンス: https://date-fns.org/v2.29.3/docs/startOfDay

ここで一度画面を確認します。

以下の URL にアクセスします。

http://localhost:3000/schedules

03_infinity-1.png

本日(執筆している 12/04)のデータから表示されました。

前のスケジュールを読み込む処理の追加

次に、前のスケジュールを読み込む処理を追加します。

useInfiniteQuery - Blitz.js

上記リファレンスに、useInfinityQuery の戻り値の情報があるので、これを元に過去方向の読み込みの実装を行います。

以下のファイルを修正します。

  • src/pages/schedules/index.tsx
diff --git a/src/pages/schedules/index.tsx b/src/pages/schedules/index.tsx
index acb561c..142695b 100644
--- a/src/pages/schedules/index.tsx
+++ b/src/pages/schedules/index.tsx
@@ -21,17 +21,40 @@ export const SchedulesList = () => {
       },
     },
   })
-  const [schedulePages, { isFetching, isFetchingNextPage, fetchNextPage, hasNextPage }] =
-    useInfiniteQuery(
-      getSchedules,
-      (page = { take: ITEMS_PER_PAGE, skip: skipCount, orderBy: { date: "asc" } }) => page,
-      {
-        getNextPageParam: (lastPage) => lastPage.nextPage,
-      }
-    )
+  const [
+    schedulePages,
+    {
+      isFetching,
+      isFetchingNextPage,
+      fetchNextPage,
+      hasNextPage,
+      isFetchingPreviousPage,
+      fetchPreviousPage,
+      hasPreviousPage,
+    },
+  ] = useInfiniteQuery(
+    getSchedules,
+    (page = { take: ITEMS_PER_PAGE, skip: skipCount, orderBy: { date: "asc" } }) => page,
+    {
+      getNextPageParam: (lastPage) => lastPage.nextPage,
+      getPreviousPageParam: (firstPage) => firstPage.nextPage,
+    }
+  )

   return (
     <div>
+      <button
+        onClick={() => fetchPreviousPage()}
+        disabled={!hasPreviousPage || !!isFetchingPreviousPage}
+      >
+        {isFetchingPreviousPage
+          ? "Loading more..."
+          : hasPreviousPage
+          ? "Load Previous Schedule More"
+          : "Nothing more to load"}
+      </button>
+      <div>{isFetching && !isFetchingPreviousPage ? "Fetching..." : null}</div>
+
       {schedulePages.map((page, i) => (
         <ul key={i}>
           {page.schedules.map((schedule) => (
@@ -50,7 +73,7 @@ export const SchedulesList = () => {
         {isFetchingNextPage
           ? "Loading more..."
           : hasNextPage
-          ? "Load More"
+          ? "Load Next Schedule More"
           : "Nothing more to load"}
       </button>
       <div>{isFetching && !isFetchingNextPage ? "Fetching..." : null}</div>

基本的には、前回追加した〜NextPage ( isFetchingNextPage , fetchNextPage, hasNextPage)に対応する、〜PreviousPage( isFetchingPreviousPage, fetchPreviousPage, hasPreviousPage)という関数があるので、同じように実装します。

過去方向の読み込みボタン( Load Previous Schedule More)が表示されました。

(合わせて未来方向のボタンの表示を修正しています)

04_infinity-2.png

過去方向の読み込みボタンを押してみます。

05_infinity-3.png

なんと、過去のデータではなく、未来のデータが表示されてしまいました。

これは、 getPreviousPageParam に渡している関数に nextPage を返すように設定しているのが悪そうです。(リファレンスにも nextPage が記載されているのですが、記載ミスでしょうか?)

getPreviousPageParam: (firstPage) => firstPage.nextPage,

nextPage ではなく、 previousPage という関数があれば良いのですが、リファレンスおよび blitz のコードを見ても存在しません。

// node_modules/blitz/dist/index-server.d.ts:143-155
declare function paginate<QueryResult>({ skip, take, maxTake, count: countQuery, query, }: PaginateArgs<QueryResult>): Promise<{
    items: Awaited<QueryResult>;
    nextPage: {
        take: number;
        skip: number;
    } | null;
    hasMore: boolean;
    pageCount: number;
    pageSize: number;
    from: number;
    to: number;
    count: number;
}>;

仕方がないので、previousPage に該当する処理を実装します。

diff --git a/src/pages/schedules/index.tsx b/src/pages/schedules/index.tsx
index 142695b..d9dfcce 100644
--- a/src/pages/schedules/index.tsx
+++ b/src/pages/schedules/index.tsx
@@ -21,6 +21,7 @@ export const SchedulesList = () => {
       },
     },
   })
+  const orderBy = { date: "asc" }
   const [
     schedulePages,
     {
@@ -34,10 +35,20 @@ export const SchedulesList = () => {
     },
   ] = useInfiniteQuery(
     getSchedules,
-    (page = { take: ITEMS_PER_PAGE, skip: skipCount, orderBy: { date: "asc" } }) => page,
+    (page = { take: ITEMS_PER_PAGE, skip: skipCount, orderBy }) => page,
     {
       getNextPageParam: (lastPage) => lastPage.nextPage,
-      getPreviousPageParam: (firstPage) => firstPage.nextPage,
+      getPreviousPageParam: (firstPage) => {
+        if (!firstPage.nextPage) return null
+        const skip = Math.max(firstPage.nextPage.skip - ITEMS_PER_PAGE * 2, 0)
+        if (skip === 0) return null
+        const take = skip < ITEMS_PER_PAGE ? skip : ITEMS_PER_PAGE
+        return {
+          take,
+          skip,
+          orderBy,
+        }
+      },
     }
   )

やったことは以下です。

  • getPreviousPageParam の戻り値に orderBy を追加したいので orderBy を変数化。
  • firstPage.nextPage には、firstPage の次のデータを表示するための skip 数が入っているため、そこから ITEMS_PER_PAGE * 2 を引き算します。

06_skip.png

  • 引き算の結果、マイナスになる可能性があるので、その場合は、skip = 0 になるよう Math.max で比較します。
  • 過去のデータがもうない場合は、 getPreviousPageParamnullを返せば、 hasPreviousPagefalse になる仕様があるので、 nullを返します。
  • 過去のデータがまだある場合は、 takeskip および orderBy を返しますが、skipの数が、1 ページの表示数より少ない場合、 skip 数分だけ取得するように takeを調整します。

無限リスト(未来および過去方向)の実装を再確認

過去方向の読み込みボタン( Load Previous Schedule More)をクリックすると、過去のスケジュールが読み込まれ、

07_matome-1.png

未来方向の読み込みボタン(Load Next Schedule More)クリックすると、未来のスケジュールが読み込まれました。

08_matome-2.png

まとめ

未来および過去方向に読み込みを行う無限リストを実装しました。

Blitz.js には 無限リストを扱うライブラリがあるため、比較的容易に実装することができました。過去方向のデータを取得する際は、スキップの件数を調整することによって、取得するデータを調整します。

ツイートするはてなブックマークに追加シェアする