[Apollo Client v3] fetchMore を使ったページネーションの実装例

2022.06.28

実現したいこと

初期ロードで 10 件のデータを画面に表示し、もっと見るボタンを押すと続きの 10 件を取得します。 このもっと見るボタンを押した時に取得した追加のデータを読み込み済みのデータにマージするために Apollo Client の typePolicies を設定します。

検証環境

  • React "18.2.0"
  • Apollo Client "3.6.9"
  • GraphQL "16.5.0"

スキーマからのクエリや型の生成には codegen を利用しています。

利用する GraphQL API

サーバーサイド API のスキーマです。 first の値に取得したいデータ数、after にページネーションの次に取得するデータの ID を渡す作りになっています。

edges の中に画面に一覧表示したいデータが配列で入ります。

type SampleEdgeNode implements BaseSample {
  id: ID!
  totalQuantity: Int!
  shopName: String!
  issuedDate: String!
  issuedAt: String!
}

type SampleEdge {
  cursor: String!
  node: SampleEdgeNode!
}

type SamplesConnection {
  pageInfo: PageInfo!
  edges: [SampleEdge!]
}

type Query {
  samples(first: Int, after: String): SamplesConnection!
}

今回取得したいデータはこのようにネストされたデータ形式になります。

{
  "data": {
    "samples": {
      "pageInfo": {
        "hasPreviousPage": false,
        "startCursor": "eyJpZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMy0xMCIsImxpbmVVc2VySWQiOiJVYTNlZmU4OWFhOGY5ZDlkMmU5ZDE0NDZhN2QyNDk2MzMiLCJpc3N1ZWRBdCI6IjIwMjItMDQtMDFUMjA6MDA6MDArMDk6MDAifQ==",
        "hasNextPage": true,
        "endCursor": "eyJpZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMy04IiwibGluZVVzZXJJZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMyIsImlzc3VlZEF0IjoiMjAyMi0wNC0wMVQxODowMDowMCswOTowMCJ9",
        "__typename": "PageInfo"
      },
      "edges": [
        {
          "cursor": "eyJpZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMy0xMCIsImxpbmVVc2VySWQiOiJVYTNlZmU4OWFhOGY5ZDlkMmU5ZDE0NDZhN2QyNDk2MzMiLCJpc3N1ZWRBdCI6IjIwMjItMDQtMDFUMjA6MDA6MDArMDk6MDAifQ==",
          "node": {
            "id": "Ua3efe89aa8f9d9d2e9d1446a7d249633-10",
            "totalQuantity": 2,
            "shopName": "A店",
            "issuedAt": "2022-04-01T20:00:00+09:00",
            "issuedDate": "2022-04-01",
            "__typename": "SampleEdgeNode"
          },
          "__typename": "SampleEdge"
        },
        {
          "cursor": "eyJpZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMy05IiwibGluZVVzZXJJZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMyIsImlzc3VlZEF0IjoiMjAyMi0wNC0wMVQxOTowMDowMCswOTowMCJ9",
          "node": {
            "id": "Ua3efe89aa8f9d9d2e9d1446a7d249633-9",
            "totalQuantity": 2,
            "shopName": "A店",
            "issuedAt": "2022-04-01T19:00:00+09:00",
            "issuedDate": "2022-04-01",
            "__typename": "SampleEdgeNode"
          },
          "__typename": "SampleEdge"
        },
        {
          "cursor": "eyJpZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMy04IiwibGluZVVzZXJJZCI6IlVhM2VmZTg5YWE4ZjlkOWQyZTlkMTQ0NmE3ZDI0OTYzMyIsImlzc3VlZEF0IjoiMjAyMi0wNC0wMVQxODowMDowMCswOTowMCJ9",
          "node": {
            "id": "Ua3efe89aa8f9d9d2e9d1446a7d249633-8",
            "totalQuantity": 2,
            "shopName": "A店",
            "issuedAt": "2022-04-01T18:00:00+09:00",
            "issuedDate": "2022-04-01",
            "__typename": "SampleEdgeNode"
          },
          "__typename": "SampleEdge"
        }
      ],
      "__typename": "SampleConnection"
    }
  }
}

useQueryでデータを取得

上記の API からクエリでデータを取得します。

query samples($first: Int, $after: String) {
  samples(first: $first, after: $after) {
    pageInfo {
      hasPreviousPage
      startCursor
      hasNextPage
      endCursor
    }
    edges {
      cursor
      node {
        id
        totalQuantity
        shopName
        issuedAt
        issuedDate
      }
    }
  }
}

コンポーネントから useQuery を使ってデータを取得します。

const {loading, data} = useQuery<SamplesQuery>(SamplesDocument, {
  variables: {
    first: 3, // 一度のコールで取得するデータの数
    cursor: null, // 初回読み込み時はnullを渡す
  },
});

スキーマからの型の生成には codegen を利用しています。 以下が自動生成された内容になります。

export const SamplesDocument = gql`
  query samples($first: Int, $after: String) {
    samples(first: $first, after: $after) {
      pageInfo {
        hasPreviousPage
        startCursor
        hasNextPage
        endCursor
      }
      edges {
        cursor
        node {
          id
          totalQuantity
          shopName
          issuedAt
          issuedDate
        }
      }
    }
  }
`;

export type SamplesQuery = {
  __typename?: "Query";
  sampless: {
    __typename?: "SampleConnection";
    pageInfo: {
      __typename?: "PageInfo";
      hasPreviousPage: boolean;
      startCursor?: string | null;
      hasNextPage: boolean;
      endCursor?: string | null;
    };
    edges?: Array<{
      __typename?: "SampleEdge";
      cursor: string;
      node: {
        __typename?: "SampleEdgeNode";
        id: string;
        totalQuantity: number;
        shopName: string;
        issuedAt: string;
        issuedDate: string;
      };
    }> | null;
  };
};

これで初期ロード時に 10 件のデータを取得できるようになりました。

fetchMore 関数で次の 10 件を取得

次にコンポーネントにもっと見るボタンと追加のデータを取得する関数を追加します。

samples.tsx

import {useQuery} from "@apollo/client";
import {FC, useCallback} from "react";
import {SamplesDocument, SamplesQuery} from "../../../generated/graphql";
import {LoadingPanel} from "../../atoms/LoadingPanel/LoadingPanel";
import {SystemError} from "../SystemError";
import {NotFoundSamples} from "./NotFoundSamples";
import {SampleCards} from "./SampleCards";

export const Samples: FC = () => {
  const {loading, data, fetchMore} = useQuery<SamplesQuery>(SamplesDocument, {
    variables: {
      first: 3, // 一度のコールで取得するデータの数
      cursor: null, // 初回ロード時はnullを渡す
    },
  });

  const loadMore = useCallback(async () => {
    // 次の10件を取得
    await fetchMore({
      variables: {
        cursor: data?.samples.pageInfo.endCursor,
      },
    });
  }, [data, fetchMore]);

  if (loading) {
    return <LoadingPanel />;
  }

  if (data === undefined) {
    return <SystemError />;
  }

  return (
    <div>
      <div className="container bg-grayLight mx-auto h-screen">
        <div className="px-6" data-test="samplesButton">
          {/* データが1件以上なら一覧ページを表示し、0件の場合はNotFoundコンポーネントを表示する */}
          {data.samples.edges != null && data?.samples.edges?.length > 0 ? (
            <SampleCards data={data} loadMore={loadMore} />
          ) : (
            <NotFoundSamples />
          )}
        </div>
      </div>
    </div>
  );
};

この実装では loadMoreが呼ばれたときに次のデータ 10 件を取得します。 何も設定しないままだと、初回ロードの時に読み込んだデータを上書きする形で Apollo のキャッシュが上書きされます。

今回は初回ロードの時に取得したデータ 10 件に次の 10 件をプラスして表示させたいため、Apollo の typePolicies の設定でデータをマージします。

typePolicies で fetchMore で取得したデータを取得済みのデータとマージする

取得済みのデータと fetchMore で新しく取得したデータをマージする処理を typePolicies に記述します。

import {ApolloClient, InMemoryCache} from "@apollo/client";
import {SamplesQuery} from "./generated/graphql";

const endpoint = (import.meta.env.VITE_API_ENDPOINT as string) ?? "";

type SamplesData = SamplesQuery["samples"];

export const client = (liffIdToken: string | null) => {
  const apolloClient = new ApolloClient({
    uri: new URL("/graphql", endpoint).toString(),
    cache: new InMemoryCache({
      typePolicies: {
        Query: {
          fields: {
            // フィールド名
            samples: {
              keyArgs: false,
              // fetchMoreで取得したデータと取得済みのデータをマージ
              // https://www.apollographql.com/docs/react/pagination/core-api/
              merge(existing: SamplesData, incoming: SamplesData) {
                return {
                  ...(incoming ?? {}),
                  edges: [
                    ...(existing?.edges ?? []),
                    ...(incoming?.edges ?? []),
                  ],
                };
              },
            },
          },
        },
      },
    }),
    headers: {
      Authorization: IdToken ?? "",
    },
  });
  return apolloClient;
};

今回のようにデータの形式がネストされている場合は以下のようにマージしたいデータを指定する必要があります。 以下の例では edges に配列で渡されるデータを取得済みのデータに追加してキャッシュするように指定しています。

References