【Next.js】Apolloと無限スクロールとミューテーション

## はじめに

普段はPHPでバックエンドの開発をおこなっているのですが、ここ最近フロントエンドを書くことも増えてきました。
そこで、「Apolloと無限スクロール」についての記事はいくつか拝見したのですが、そこにミューテーション（更新・削除）が加わった場合の記事が見当たりませんでした。

今回は「Apolloのキャッシュ」に着目して、無限スクロールやミューテーション後のUIの更新について書いてみたいと思います。

### ライブラリ

- Next.js 12.0.7
- Apollo Client 3.5.6
- react-infinite-scroll-component 6.1.0

### 今回使用するGraphQlクエリ

コメント一覧を想定します。
要件は下記の通りです。

- コメントを無限スクロールで一覧できる
- 各コメントに削除ボタンが存在し、押下でコメントを削除できる

```ts
const FETCH_COMMENTS_BY_POST_ID = gql`
  query fetchCommentsByPostId($post_id: ID!, $page: Int = 1) {
    fetchCommentsByPostId(post_id: $post_id, page: $page, first: 20) {
      data {
        id
        comment
      }
      paginatorInfo {
        currentPage
        hasMorePages
      }
    }
  }
`;

const DELETE_COMMENT = gql`
  mutation deleteComment($id: ID!) {
    deleteComment(id: $id) {
      id
    }
  }
`;
```

バックエンドは [Lighthouse - Laravel](https://lighthouse-php.com/) 想定です。
ページネーションが少し独特ですが、適宜読み替えていただければと思います。

## Apollo Clientとサーバサイドレンダリング

[Apollo Client](https://www.apollographql.com/docs/react) とは、JavaScript 用の GraphQL クライアントライブラリです。詳細はもっと多機能なのですが、今回はその中でもキャッシュ機構に着目します。

Apollo Client は GraphQL で取得したデータをインメモリのキャッシュに保存します。そして、次回以降の同じ内容のリクエストに対しては、バックエンドに問い合わせるのではなく、キャッシュにある結果を即座に返します。
この挙動は [`fetchPolicy` オプション](https://www.apollographql.com/docs/react/data/queries/#supported-fetch-policies)で変更可能ですが、デフォルトでは `cache-first` が設定されていて、まずキャッシュを見に行って、データがなければバックエンドに問い合わせるという挙動になっています。

このキャッシュ機構を利用することで、サーバサイドレンダリングにおいて `getServerSideProps()` 内で必要なデータをクエリしてキャッシュを作り、レンダリング時に必要なデータをクライアントから即座に取得するといったことが可能になります。

実装の詳細は下記参考をご覧ください。
やっていることは、サーバサイドとクライアントサイドでインメモリキャッシュの共有はできないので、サーバサイドで作ったキャッシュを Props としてクライアントサイドに渡して、クライアントサイドのインメモリキャッシュにマージするというものです。

参考：[Apollo Example - vercel](https://github.com/vercel/next.js/blob/canary/examples/with-apollo/lib/apolloClient.js)

`getServerSideProps()` は下記のようになります。

```ts
export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
  const postId = params?.postId;

  // 参考の`lib/apolloClient.ts`の関数
  // Apollo Client インスタンスの生成
  const apolloClient = initializeApollo();

  // クエリの実行（ここでサーバサイドのキャッシュに保存される）
  await apolloClient.query({
    query: FETCH_COMMENTS_BY_POST_ID,
    variables: {
      post_id: postId,
    },
  });

  // 参考の`lib/apolloClient.ts`の関数
  // キャッシュのデータを取り出して、Props として渡す
  return addApolloState(apolloClient, {
    props: {
      postId: postId,
    },
  });
};
```

こうすることで、`pages`, `components`のどこからでも、`useQuery(FETCH_COMMENTS_BY_POST_ID, {variables: {post_id: postId,}});`でバックエンドに問い合わせることなく、キャッシュから即座にデータが利用できるようになります。つまり、レンダリング時にデータが入った状態のHTMLを生成することができます。

## 無限スクロールの実装

それでは実際に無限スクロールを実装していきたいと思います。
先ほどから述べているように Apollo Client のキャッシュ機構に着目して実装します。具体的には、`useQuery()`はキャッシュの変更を検知してUIに反映できるので、無限スクロール時にキャッシュを都度更新していくという戦略を取ります。

### Apollo Client の設定

まず、Apollo Client のキャッシュの設定を変更します。
デフォルトではリクエスト内容ごとにキャッシュを作成しますが、今回は`page`, `first`パラメータが異なっていても同一のキャッシュとして扱うように設定します。
こうすることで、`post_id`に紐づくコメント一覧をページに関係なく、一つのキャッシュとして扱うことができます。

先ほどの `lib/apolloClient.ts` に以下を追加します。
参考：[Defining a field policy - Core pagination API](https://www.apollographql.com/docs/react/pagination/core-api#defining-a-field-policy)

```ts:apolloClient.ts
・・・
const createApolloClient = () => {
  return new ApolloClient({
    // 省略
    cache: new InMemoryCache({
      typePolicies: {
        Query: {
          fields: {
            fetchCommentsByPostId: { // 対象のクエリ名を指定
              keyArgs: ['post_id'],  // キャッシュの区別に使用するリクエストパラメータ
              merge: (existing: any, incoming: any) => { // キャッシュのマージ方法を定義
                if (!existing) return incoming;
                return {
                  data: [...existing.data, ...incoming.data],
                  paginatorInfo: incoming.paginatorInfo,
                };
              },
            },
          },
        },
      },
    }),
  });
};
・・・
```

### useQuery と fetchMore

次にコンポーネント内でどのようにデータを取得するかを記述します。
こちらには `useQuery()` の返り値の [fetchMore](https://www.apollographql.com/docs/react/pagination/core-api#the-fetchmore-function)を使用します。

実装例は下記です。
CommentListコンポーネントは、先ほど定義した `getServerSideProps()` がある `pages` 配下のコンポーネントとします。

```ts
const CommentList: FC<Props> = ({ postId }) => {
  // コメントの配列
  const [comments, setComments] = useState<Comment[]>([]);
  // 現在のページ番号
  const [page, setPage] = useState(1);
  // 次のページが存在するか
  const [hasMorePages, setHasMorePages] = useState(true);

  const { fetchMore } = useQuery(FETCH_COMMENTS_BY_POST_ID, {
    variables: {
      post_id: postId,
    },
    onCompleted: ({ fetchCommentsByPostId }) => {
      const { data, paginatorInfo } = fetchCommentsByPostId;
      setComments(data);
      setPage(paginatorInfo.currentPage);
      setHasMorePages(paginatorInfo.hasMorePages);
    },
  });

  // 無限スクロール時に発火させる関数
  const fetchMoreComments = async () => {
    await fetchMore({
      variables: {
        // 変更箇所だけ渡す（post_idは固定）
        page: page + 1,
      },
    });
  };

  return // 省略
```

データフェッチの流れは下記のようになります。

1. レンダリング時にキャッシュにある１ページ目のデータを取得して、`comments`, `page`, `hasMorePages` をセットする
1. 無限スクロール時に `fetchMoreComments()`を発火させて、次のページのデータを取得して、同一クエリのキャッシュを更新する
1. `useQuery()`がキャッシュの変更を検知して、再び `comments`, `page`, `hasMorePages` をセットする
1. 2に戻る

### react-infinite-scroll-component

コンポーネントの無限スクロールを簡単に実装するために、[react-infinite-scroll-component](https://github.com/ankeetmaini/react-infinite-scroll-component) を使用します。

使用方法は、無限スクロールで繰り返したいコンポーネントを `<InfiniteScroll>`直下に配置します。

必須のプロパティは下記です。

- next
  - スクロール量が閾値（デフォルト：80%）を超えたら発火する関数
- hasMore
  - スクロール量が閾値を超えたときに `next` を実行するか否か
- dataLength
  - 現在の子要素の数
- loader
  - `next` 実行中（ローディング中）の表示内容

先ほどの実装例に以下を追加します。

```ts
import InfiniteScroll from 'react-infinite-scroll-component';

const CommentList: FC<Props> = ({ postId }) => {
  // 省略（「useQueryとfetchMore」の内容）
  return (
    <InfiniteScroll
      next={fetchMoreComments}
      hasMore={hasMorePages}
      dataLength={comments.length}
      loader={<h4>Loading...</h4>}
    >
      {comments.map((comment) => (
        <Comment key={comment.id} comment={comment.comment} />
      ))}
    </InfiniteScroll>
  );
};
```

## ミューテーション後のUIの更新

これで無限スクロールの実装までは完了しました。
実は、無限スクロールだけで良ければ、[上記の Apollo Client の設定](#apollo-client-の設定)は必要ありません。`fetchMoreComments()` の中で `setComments((prev) => [...prev, ...data])` を呼べば同じように動作します。

ではなぜ、そのような設定をわざわざおこなったかというと、それはミューテーション（今回の例はコメント削除）後にUIを更新するのに都合が良いからです。

`useMutation()` に [refetchQueries](https://www.apollographql.com/docs/react/data/mutations#refetching-queries) オプションを渡すと、ミューテーション完了後に該当のクエリをバックエンドに問い合わせてくれます。
そして、その結果でキャッシュが更新されるので、`fetchMore` のときと同様に `useQuery()`がキャッシュの変更を検知して、UIを更新してくれるようになります。

先ほどの実装例に削除機能を追加します。

```ts
const CommentList: FC<Props> = ({ postId }) => {
  // 省略（「useQueryとfetchMore」の内容）

  const [deleteComment] = useMutation(DELETE_COMMENT, {
    refetchQueries: [{ // ミューテーション完了後に発火するクエリ
        query: FETCH_COMMENTS_BY_POST_ID,
        variables: { post_id: postId },
    }],
  });

  return (
    <InfiniteScroll
      next={fetchMoreComments}
      hasMore={hasMorePages}
      dataLength={comments.length}
      loader={<h4>Loading...</h4>}
    >
      {comments.map((comment) => (
        <Comment
          key={comment.id}
          comment={comment.comment}
          handleDeleteBtnClick={async () => {
            await deleteComment({
              variables: {
                id: comment.id,
              },
            });
          }}
        />
      ))}
    </InfiniteScroll>
  );
};
```

`refetchQueries` を使用するにあたって一点注意が必要なのは、実際にバックエンドに問い合わせるので、一回のクエリでは１ページ目のデータしか取得できません。
その後、現在のスクロール量に応じて、`<InfiniteScroll>` が `fetchMoreComments()` を都度実行していきます。
ですので、一番下までスクロールした後に「削除」を実行すると、少しUIがカクついて見えます。

こちらが気になる方は下記を参考にして、直接キャッシュを変更することも可能です。
今回、この方法を採用しなかった理由は、コンポーネント内の処理が複雑になるのがイヤだったのと、定期的にバックエンドとデータの同期を取った方が良いと判断したからです。

参考：[Updating the cache directly - Mutations in Apollo Client](https://www.apollographql.com/docs/react/data/mutations#the-update-function)

## さいごに

「Apolloのキャッシュ」に着目して、無限スクロールやミューテーション後のUIの更新についてご紹介しました。

より良い方法があれば、ぜひ教えていただきたいです。