DMM.comの、一番深くておもしろいトコロ。

SWRを活用してページネーションの実装を工夫した話

SWRを活用してページネーションの実装を工夫した話

  • このエントリーをはてなブックマークに追加

この記事は、DMMグループAdvent Calendar 2021の14日目の記事です。

こんにちは。DMMのプラットフォーム(以下PFと略称)事業本部所属の佐々木勝春と申します。

私が所属するチームでは現在「DMMポイントクラブ」というDMMのポイントを獲得したりチャージしたり、履歴の確認などができるアプリ(iOS/AndroidとWeb)を運営しております。私はこの両システムのサーバーサイドの開発とWeb版のフロントの開発を担当しております。

lp.pointclub.dmm.com

今回はこちらのWeb版ポイントクラブのポイント管理ページにあるポイント履歴一覧のページネーションを実装する際に工夫した点についてご紹介させていただきます。

Web版ポイントクラブのフロントエンドのシステム構成概要

前提として、まずWeb版ポイントクラブのシステム構成の概要について簡単に説明いたします。 フロントエンドの実装にUIライブラリであるReactを採用していて、 インフラ環境はAWSのCloudFrontとS3を活用してファイルを配信しています。

ja.reactjs.org

本題

ポイント履歴一覧のページネーションのデザイン案

ユーザーのポイントの獲得・消費などのポイント使用履歴一覧や保有しているポイントの有効期限の一覧を、下記のポイント利用情報のセクションで表示します。ポイントの履歴を複数表示するためにページネーションを活用しています。

f:id:dmmadcale2021:20211207044620j:plain
ポイント利用情報のデザイン原案

サービス間のシステム構成

ここで具体的なシステム構成について見ていきます。 上記ポイント履歴情報を取得するにあたって、ブラウザ側でポイントクラブチームで開発したバックエンドのAPIにリクエストして情報を取得し、その結果を表示に利用しています。 ポイントクラブAPI内部ではDMMのPFをはじめとする各マイクロサービスにリクエストを行なうことでデータのやり取りを行なっています。その一つであるDMMポイントに関する機能を提供するポイントAPIにリクエストを行いポイント情報履歴を取得して、それをポイントクラブAPIでクライアント側に適切な形式に加工してあげて返却する構成になっています。

f:id:dmmadcale2021:20211207044917p:plain
システム構成概念図(システムのグルーピングや認証ゲートウェイなどは省略)

ページネーション実装にあたっての課題

上述の外部サービスのポイントAPIの利用にあたって一つの課題となったのがポイントの履歴の取得件数が最大で50件までという制限でした。 上述のWeb版のポイント履歴のデザインの原案としてはページネーションのページのあるページ数分(今回は4ページ分)ページを表示して、それ以上のページデータを保有している場合は三点リーダで省略表示した後最後のページの数を表示するようなデザイン案でした。

しかし、最大取得件数の制限より上記デザイン原案の最大ページ数の表示の実現が困難になりました。

f:id:dmmadcale2021:20211207045205p:plain

対応策

最後のページ数は取得できないものの、下記のようにある指定の表示幅のページ数を表示して(今回の場合は4ページ分)、 それ以上のページ数のデータがある場合は三点リーダーで省略表示し、ページネーション押下毎にページ表示幅以降のデータを取得してきて表示する方法を考えました。この方法であればポイントAPIでの取得最大件数の制約下でも実装できることが分かりました。

f:id:dmmadcale2021:20211207045421p:plain
ページネーションデザイン修正案

対応策の実装方法検討

上記の対応策を実装するにあたって考慮点がいくつかありました。 各ページインデックス毎のデータを管理しなければならず、また複数ページ表示のためにあらかじめデータを取得しておく必要がありました。 また、ページネーションのクリックイベント時にデータ取得でAPIリクエストする際に、それまでのポイントクラブの実装ではAPIリクエストによるデータ取得用ライブラリのSWRをラップしてHook関数化したものを利用していました。しかし、Hook関数のルールではJavaScript関数内部からHook関数を直接呼ぶことができず、ページネーション実装の部分だけデータ取得のこの実装形式を統一できないなど、もやもやする部分がありました。

swr.vercel.app

const handlePaginate = (selectedItem: { selected: number }) => {
  const { data } = usePointHistory() // JavaScript関数内部からはHook関数は呼べない
  setCurrentPage(selectedItem.selected);
};

フックのルール – React

useSWRInfiniteを活用

ページネーション の実装に関して調査した結果、上述のデータ取得ライブラリSWRの1機能としてページネーション実装用に useSWRInfinite というモジュールが提供されていることが分かりました。 下記で詳細を見ていきますが、このモジュールを使うことでとても簡単にページネーション関連を実装できることが分かりました。

swr.vercel.app

useSWRInfinite定義

import useSWRInfinite from 'swr/infinite'

// ...
const { data, error, isValidating, mutate, size, setSize } = useSWRInfinite(
  getKey, fetcher?, options?
)

引数

getKeyページのインデックス(ページ数)と全ページのデータを引数に取る関数でレスポンスとしてページのキーを返します。 fetcheruseSWRと同様にデータをフェッチするためのPromiseを返す関数を指定します。 optionsuseSWRと同様の設定に加えて、下記のページネーション特有のオプションがあります。

  • initialSize = 1: 最初にロードするページ数
  • revalidateAll = false: 常にすべてのページに対して再検証を試みる設定
  • revalidateFirstPage = true: 常に最初のページに対して再検証を試みる設定
  • persistSize = false: 最初のページのキーが変更されたときに、ページサイズを 1 (またはセットされていれば initialSize)にリセットしない

レスポンス

上記レスポンスの data, error, isValidating, mutate に関してはuseSWR と基本同じ内容のため割愛します。

それ以外のレスポンスsizeに関して、フェッチ後に返されうる想定ページ数の値で、 setSizeは初回以降のページのデータを取得していく際にAPIリクエストに指定するページ数をセットする関数です (ex: ページ数2のデータを取得したい場合→ setSize(2)を実行します)

さらなるAPIの詳細については下記を参考ください。 下記以降でWeb版ポイントクラブでのポイント履歴のページネーションの実装例を具体的に見ていきます。

参考 https://swr.vercel.app/ja/docs/pagination#useswrinfinite

ポイント履歴一覧での実装例

下記はuseSWRInfiniteを利用したAPIリクエスト用のHook関数の定義です。 (import文や変数宣言部分など諸々省略して本質的な部分のみ簡略的に記載しています)

データ取得用Hook関数定義

import { useState, useEffect } from 'react';
import useSWRInfinite from 'swr/infinite'

const usePointHistory = (
  fromUnixTime: number,
  toUnixTime: number
): PointHistoryListResponse => {
   const { data, size, setSize } = useSWRInfinite<FetchPointHistoryResponse>(
    (pageIndex: number, previousPageData: FetchPointHistoryResponse | null) => {
      if (previousPageData && !previousPageData.length) return null;
      return `/v1/points/history?page=${pageIndex}&per=10&from=${fromUnixTime}&to=${toUnixTime}`;
    },
    pointClubAPIFetcher,
    {
      initialSize: 5, // プリフェッチで事前に5ページ分初回ページロード時にデータを取得する
    }
  );

  const [res, setRes] = useState<PointHistoryListResponse>({
    pointHistoryList: [],
    page: 0,
  });

  useEffect(() => {
    if (!data) return;

    setRes({
      pointHistoryList: data,
      page: size,
      setPage: setSize,
    });
  }, [data, size, setSize]);

  return res;
};

export default usePointHistory;

下記部分で useSWRInfiniteを呼び出しています。 第一引数のgetKey部分には現在のページインデックスに加えて、前ページのデータを引数にとる関数が指定されているため、インデックスとカーソルベースの両方のタイプのページネーションの実装に活用できます。 第二引数にデータ取得用Fetcher関数を指定して、第三引数でoptionを指定しています。 option{initialSize: 5}を指定することにより初回レンダー時にデフォルトの1ページ分ではなく5ページ分のデータをあらかじめAPIリクエストして習得してくれるようになります。

const { data, size, setSize } = useSWRInfinite<FetchPointHistoryResponse>(
    (pageIndex: number, previousPageData: FetchPointHistoryResponse | null) => {
      if (previousPageData && !previousPageData.length) return null;
      return `/v1/points/history?page=${pageIndex}&per=10&from=${fromUnixTime}&to=${toUnixTime}`;
    },
    pointClubAPIFetcher,
    {
      initialSize: 5,
    }

f:id:dmmadcale2021:20211207051300p:plain
ブラウザでの初回ページ遷移時のリクエストログ

そしてレスポンスに関して、レスポンスデータを格納するdataと、取得したページ数を返す size,そして次ページ以降取得するページ数をセットするためのsetSize関数を受け取ります。 注目の点としてdataの構造が各ページ数をキーにそのレスポンスを配列で格納する二重配列の構造になっています。

// `data` はこのようになります
[
  // 1ページ目のAPIリクエストのレスポンスを格納
  [
    { id: 'xxxx', pointAmount: 500, ... },
    { id: 'xxxx', pointAmount: 200, ... },
    { id: 'xxxx', pointAmount: 400, ... },
    ...
  ],
  // 2ページ目
  [
    { id: 'xxxx', pointAmount: -300, ... },
    { id: 'xxxx', pointAmount: 600, ... },
    { id: 'xxxx', pointAmount: 50, ... },
    ...
  ],
  ...
]

ポイント履歴一覧(ページネーション)コンポーネント

上記のHook関数を利用してポイント履歴一覧を表示するコンポーネントが下記になります。

import React, { useState } from 'react';
import PointHistoryListItem from '../HistoryPointListItem';
import styles from './index.module.scss';
import Paginate from '../Paginate';
import usePointHistory from '../../hooks/usePointHistory';

const HistoryPointList: React.FC = () => {
  const [currentPage, setCurrentPage] = useState(0);
  const { pointHistoryList, page, setPage } = usePointHistory(fromUnixTime, toUnixTime);

  let totalItems = 0; // 全ページでのポイント履歴の総件数
  for (let i = 0; i < pointHistoryList.length; i += 1) {
    totalItems += pointHistoryList[i].length;
  }

  const handlePaginate = (selectedItem: { selected: number }) => {
    setCurrentPage(selectedItem.selected);
    if (setPage) {
      setPage(page + 1);
    }
  };


  return (
    <>
      <div className={styles.area_list}>
        <ul className={styles.box_list}>
          <li>
            {pointHistoryList[currentPage].map((item) => (
                  <PointHistoryListItem
                    key={item.id}
                    points={item.points}
                    totalPoint={item.totalPoint}
                  />
                ))
              }
          </li>
        </ul>
      </div>
      <div className={styles.paginate_wrapper}>
        <Paginate
          pageCount={totalItems / listItemCountPerPage}
          handlePaginate={handlePaginate}
        />
      </div>
    </>
  );
};

export default HistoryPointList;

下記部分でページネーションデータ取得用のHook関数を呼び出しています。

const { pointHistoryList, page, setPage } = usePointHistory(fromUnixTime, toUnixTime);

hook関数のレスポンスpointHistoryListからページネーション全体でのポイント履歴の件数を計算しています。

  let totalItems = 0; // 全ページでのポイント履歴の総件数
  for (let i = 0; i < pointHistoryList.length; i += 1) {
    totalItems += pointHistoryList[i].length;
  }

そしてページネーションのイベントハンドラを下記で定義しています。 現在のページ数をステートで定義し(currentPage),イベントハンドラでSetStateでページ数を更新しています。 さらに、指定されたページ数でポイント履歴一覧取得APIのリクエストを行うように setPageでページ数を設定します。 ここで指定したページのデータを既に取得済みの場合はSWRの方でよしなにキャッシュから値を返すようにしてくれます。

const handlePaginate = (selectedItem: { selected: number }) => {
  setCurrentPage(selectedItem.selected);
  if (setPage) {
    setPage(page + 1);
  }
};

まとめ

データ取得ライブラリSWRの1モジュールである useSWRInfiniteを活用することでページネーションが楽に実装する点についてみていきました。 SWRはキャッシュをうまく有効活用してくれたり、リクエストでエラーが発生した場合の再検証の実装が楽にできたりとAPIリクエスト周りの処理をとても簡潔に実装できます。

明日DMMグループAdvent Calendar 202115日目は oda-daisuke さんです。

宣伝

DMMポイントクラブ(スマホアプリ版)では現在下記のキャンペーンを実施中です。 DMMポイントを活用してDMMの多種多様なサービスを体験してください。

lp.pointclub.dmm.com

lp.pointclub.dmm.com