👋

【Apollo Client】無限スクロールとミューテーション(削除)の組み合わせで考えること👀

2022/12/26に公開

突然ですが、apolloでミューテーションを叩いた後に、すぐrefetchやrefetchqueriesを行なっていないでしょうか。

refetchqueriesはuseMutationのオプションの一つで、ミューテーションが成功した際に、

キャッシュを更新する目的で非常に有効です。

実装面においても記述量が少なく、開発カロリーは低いです。

ですが私はmutationを行なったら、refetchを行なってキャッシュを更新するという方針はおすすめしない考えです。

「無限スクロールとミューテーション(削除)の組み合わせ」のケースは特にそう考えています。

以下のQiitaの記事の実装だと、
mutationが成功した際に、refetchqueriesを叩いて、キャッシュの状態を
1ページ目のものだけにして、スクロール量に応じてfetchMoreが画面上でどんどん実行されていくため、mutation後に画面がカクついて見えます。dbとデータを同期できるというメリットもありますが、
もし100ページ目でミューテーションを走らした場合、100ページ分のfetchMoreが動くことになるので
ネットワーク通信が非常に多くなります。
https://qiita.com/yiwiy9/items/60558d368773dcc41144
https://www.npmjs.com/package/react-infinite-scroll-component

自分はこの無限スクロールとミューテーションの組み合わせについて、キャッシュを操作するアプローチを試しました。

キャッシュの操作を行う前の状態

今回のメインのテーマでないので、軽く読み流してください。

APIのスキーマ

type Query {
 fetchPlayers: Player! @paginate
}
type Mutation {
    deletePlayer(id: ID!): Player @delete
}
type Player {
    id: ID!
    name: String!
}

超シンプルなスキーマになります。APIはlaravelにlighthouseを導入して構築しました。
https://lighthouse-php.com/

フロントエンド

以下が叩くクエリになります。命名からどのようなクエリか予想つくと思います。

import { gql } from "@apollo/client";

export const FETCH_PLAYERS = gql`
  query fetchPlayers($first: Int! = 10, $page: Int = 1) {
    fetchPlayers(first: $first, page: $page) {
      data {
        id
        name
      }
      paginatorInfo {
        currentPage
        hasMorePages
      }
    }
  }
`;

export const DELETE_PLAYER = gql`
mutation deletePlayer(id: ID!) {
    deletePlayer(id: $id){
        id
        name
    }
}
`;

以下でfetchPlayersを叩いた際のキャッシュのマージ方法については以下のように設定してます。
本当はもっと厳密に設定するべきですが、割愛しました。

const client = new ApolloClient({
  uri: "http://127.0.0.1:8000/graphql",
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          fetchPlayers: {
            keyArgs: false,
            merge(existing, incoming) {
              if (!existing) return incoming;
              const data = [...existing.data, ...incoming.data];
              const paginatorInfo = incoming.paginatorInfo;
              return {
                data,
                paginatorInfo,
              };
            },
          },
        },
      },
    },
  }),
});

画面の実装
超シンプルな作りになります。この状態で削除ボタンを押しても、キャッシュは更新されないので、
UIは更新されません。
useQueryはキャッシュの変更を感知して、動作してくれるので、
その性質を利用して、UIが更新されるように修正を加えていくこととします。

import { useState } from "react";
import { useMutation, useQuery } from "@apollo/client";

import InfiniteScroll from "react-infinite-scroll-component";

import { DELETE_PLAYER, FETCH_PLAYERS } from "../gql/player";

type Player = {
  id: string;
  name: string;
};

const PlayerList = () => {
  const [players, setPlayers] = useState<Player[]>([]);
  const [hasMorePages, setHasMorePages] = useState(true);
  const [currentPage, setCurrentPage] = useState(1);
  const { fetchMore } = useQuery(FETCH_PLAYERS, {
    onCompleted({ fetchPlayers }) {
      const Arr: Player[] = [];
      fetchPlayers.data.map(
        (player: { __typename: "Player"; id: string; name: string }) =>
          Arr.push({
            id: player.id,
            name: player.name,
          })
      );
      setPlayers(Arr);
      setHasMorePages(fetchPlayers.paginatorInfo.hasMorePages);
      setCurrentPage(fetchPlayers.paginatorInfo.currentPage);
    },
  });

  const [deletePlayer] = useMutation(DELETE_PLAYER);

  const next = async () => {
    await fetchMore({
      variables: {
        page: currentPage + 1,
      },
    });
  };

  const onClickPlayerDelete = (id: string) => {
    deletePlayer({
      variables: {
        id,
      },
    });
  };
  return (
    <>
      <InfiniteScroll
        dataLength={players.length}
        next={next}
        hasMore={hasMorePages}
        loader={<h4>Loading...</h4>}
        height={"100vh"}
      >
        <div>
          {players.map((player) => (
            <div
              key={player.id}
              style={{
                height: "250px",
              }}
            >
              <div>{player.id}</div>
              <div>{player.name}</div>
              <div>
                <button
                  onClick={() => {
                    onClickPlayerDelete(player.id);
                  }}
                >
                  削除
                </button>
              </div>
            </div>
          ))}
        </div>
      </InfiniteScroll>
    </>
  );
};

export default PlayerList;

削除が成功したときに、updateオプションの中でevictを利用して、削除対象のキャッシュを削除する方法

こちらApolloのキャッシュについて以下のzennがわかりやすいのですが、
https://zenn.dev/kazu777/articles/b64935ea7d6fee
今回叩くクエリはPlayerオブジェクトを返します。そのため__typenameがPlayerとなり、キャッシュの識別子はPlayer:idとなります。言い換えればPlayer:idがキャッシュへの参照を持っていると言えます。

apolloで用意されているevictメソッドは、以下のようにキャッシュの識別子を指定してあげることで、正規化されたキャッシュを削除してくれます。

{id : __typename:id}

https://www.apollographql.com/docs/react/caching/garbage-collection/#cacheevict

上記を踏まえて、削除するPlayerのキャッシュの識別子を取得して、evictを使用することとします。

以下のように修正を加えました。
useMutationのupdateオプションを利用して、キャッシュ操作を行います。cacheには現時点でのcacheの状態を取得ことができ、dataにはmutationで取得したデータが入っています。mutationのスキーマ定義で削除したPlayerを取得できるようにしていました。

  const [deletePlayer] = useMutation(DELETE_PLAYER, {
+    update(cache, { data }) {
+      const id = cache.identify(data.deletePlayer);
+      cache.evict({ id });
+      cache.gc();
+    },
  });

cache.identifyの引数にオブジェクトを指定することでキャッシュの識別子(キャッシュID)を取得することができます。

これでキャッシュが変更され、それを感知したuseQueryが再度cache-firstで動作して、UIが更新されます。

もし一覧のPlayerのデータを最新のものに更新したい場合は、refetchを叩いて、キャッシュを一旦上書きするのもいいと思います。

個人的にはpullしてrefetchを叩かせるのが、良いのではと考えています!
https://zenn.dev/jordan23/articles/f65c069ff21364

Discussion