Closed2

SWRのページネーションと楽観的更新

Taichi MasuyamaTaichi Masuyama

想定


type PostID = string;
type Post = { id: PostID };
type Data = { posts: Post[]; nextCursor: PostID | null };

type Arguments = {
  endpoint: "/api/posts";
  cursor: string | null;
  conditions: object[];
} | null;

const useInfinitePosts = ({ conditions }: { conditions: object[] }) => {
  const keyLoader: SWRInfiniteKeyLoader<Data, Arguments> = useCallback(
    (index, previousPageData) => {
      if (previousPageData && previousPageData.nextCursor == null) {
        // NOTE: これ以上データがない場合は null を返す
        return null;
      }

      // NOTE: 1 ページ目の場合は previousPageData がない
      if (index === 0 || previousPageData == null) {
        return {
          endpoint: "/api/posts",
          cursor: null,
          conditions,
        } as const;
      }

      return {
        endpoint: "/api/posts",
        cursor: previousPageData.nextCursor,
        conditions,
      } as const;
    },
    [conditions]
  );

  const swrRes = useSWRInfinite<Data, Error, SWRInfiniteKeyLoader<Data, Arguments>>(
    keyLoader,
    async ({ endpoint, cursor, conditions }) => {
      return apiClient.post(endpoint, {
        cursor,
        conditions,
      });
    },
    { keepPreviousData: true }
  );

  /**
   * ページングの結果のうち、置き換えたいレコード1つを渡して楽観的更新する
   */
  const mutateWithOptimisticUpdate = useCallback(
    async (newPost: Post, waitPromise: Promise<unknown> = new Promise((r) => r(void 0))) => {
      const generateNewData = (prev: Data[] | undefined): Data[] | undefined => {
        if (!prev) {
          return prev;
        }

        const next = [...prev];
        const targetPageIndex = next.findIndex((page) =>
          page.posts.some((n) => n.id === newPost.id)
        );
        if (targetPageIndex === -1) {
          return prev;
        }
        const targetPage = next[targetPageIndex];
        if (!targetPage) {
          return prev;
        }
        const targetIndex = targetPage.posts.findIndex((n) => n.id === newPost.id);
        if (targetIndex === -1) {
          return prev;
        }

        const newPage = {
          ...targetPage,
          posts: [
            ...targetPage.posts.slice(0, targetIndex),
            newPost,
            ...targetPage.posts.slice(targetIndex + 1),
          ],
        };

        next[targetPageIndex] = newPage;
        return next;
      };

      await swrRes.mutate(
        async (data) => {
          await waitPromise;
          return generateNewData(data);
        },
        {
          optimisticData: swrRes.data
            ? (prev) => generateNewData(prev) ?? [] // NOTE: optimisticData はコールバックの返り値として Data[] しか受け付けないので妥協 (swrRes.data に依存させることで、実質的に data が undefined になるケースを除外できている)
            : undefined,
          rollbackOnError: true,
        }
      );
    },
    [swrRes]
  );

  const loadNextPage = useCallback(() => swrRes.setSize((s) => s + 1), [swrRes]);

  return Object.assign(swrRes, {
    mutateWithOptimisticUpdate,
    loadNextPage,
  });
};

Taichi MasuyamaTaichi Masuyama
  • ページ単位で楽観的更新できたら大量にページが読み込まれている場合でもパフォーマンスを担保できたなあと思う
  • https://swr.vercel.app/ja/docs/pagination#revalidate-specific-pages のとおり、再検証だけであれば特定のページに絞ることはできる
    • 応用して、特定のページ以降のページ全てのように再検証することもできるはずなので、必要であれば適用したらいい
このスクラップは2024/09/10にクローズされました