Server Actionsによる無限スクロール

2023年6月5日
nextjs
react
この記事では、Next.js 13のServer Actionsを利用して、無限スクロールを実装する方法を紹介します。

本記事の完成品です。

はじめに

Next.js 13 では、Server Actions という機能が追加されました。これは、サーバーサイドで実行される関数を定義することができる機能です。今回は、この Server Actions を利用して、無限スクロールを実装する方法を紹介します。

事前準備

Server Actions を利用するためには、next.config.jsに以下の設定を追加する必要があります。

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: true,
  },
};
 
module.exports = nextConfig;

Jsonplaceholder API

今回は、Jsonplaceholderという API を利用します。これは、テスト用の API で、以下のようなエンドポイントが用意されています。

こちらは、_start_limitというクエリパラメータを指定することで、取得するデータの開始位置と取得するデータの数を指定することができます。

無限スクロールの実装

まずは、app/page.tsxを以下のように編集します。

import Image from 'next/image';
 
import LoadMore from '@/components/LoadMore';
import { Card } from '@/components/ui/card';
 
const PAGE_SIZE = 10;
 
type PostType = {
  userId: number;
  id: number;
  title: string;
  url: string;
  thumbnailUrl: string;
};
 
const getPosts = async (offset: number = 0) => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts?_start=${offset}&_limit=${PAGE_SIZE}`);
  const json = await res.json();
 
  return json as PostType[] | [];
};
 
const PostList = async ({ posts }: { posts: PostType[] }) => {
  return (
    <>
      {posts?.map((post: PostType) => (
        <Card key={post.id}>
          <div className="flex flex-col items-center p-4 gap-4">
            <Image src={post.thumbnailUrl} alt={post.title} />
            <span className="text-2xl font-bold text-black capitalize">{post.title}</span>
          </div>
        </Card>
      ))}
    </>
  );
};
 
async function loadMorePost(offset: number = 0) {
  'use server';
  const post = await getPosts(offset);
 
  const nextOffset = post.length >= PAGE_SIZE ? offset + PAGE_SIZE : null;
 
  return [
    // @ts-expect-error async RSC
    <PostList offset={offset} posts={post} key={offset} />,
    nextOffset,
  ] as const;
}
 
export default async function Home() {
  const initialPosts = await getPosts(0);
 
  return (
    <main className="flex min-h-screen flex-col container mb-8 mt-32">
      <h1 className="text-2xl md:text-4xl font-bold mb-8 text-black text-center">
        Infinite Scroll Server Actions Example
      </h1>
 
      <div className="flex flex-col gap-4 items-center">
        <LoadMore loadMoreAction={loadMorePost} initialOffset={PAGE_SIZE}>
          {/* @ts-expect-error async RSC */}
          <PostList posts={initialPosts} />
        </LoadMore>
      </div>
    </main>
  );
}

コードの説明をします。

  • getPosts関数は、offsetを指定することで、指定した位置からPAGE_SIZE分のデータを取得する関数です。

  • PostListコンポーネントは、postsを受け取って、Cardコンポーネントを表示するコンポーネントです。

  • loadMorePost 関数は、次のページの投稿をロードします。この関数は、新たに投稿を取得し、新しいオフセットを計算します。もし新たに取得した投稿の数がページのサイズ(この場合は 20)より少なければ、新しいオフセットは null となります。これは、これ以上投稿がないことを意味します。

    • use serverというコメントを付けることで、Server Actions(サーバーサイドで実行される関数)であることを示しています。
    • また、offsetを受け取って、PostListコンポーネントと、次のオフセットを返します。(例えば、offsetが 0 の場合は、PostListコンポーネントと 20 を返します。offsetが 20 の場合は、PostListコンポーネントと 40 を返します。...etc)
  • Homeコンポーネントは、初期のpostsを取得して、LoadMoreコンポーネントを表示するコンポーネントです。

次に、app/components/LoadMore.tsxを以下のように編集します。

'use client';
 
import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react';
 
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
 
type loadMoreAction<T extends string | number = any> = T extends number
  ? (offset: T) => Promise<readonly [JSX.Element, number | null]>
  : T extends string
  ? (offset: T) => Promise<readonly [JSX.Element, string | null]>
  : any;
 
const LoadMore = <T extends string | number = any>({
  children,
  initialOffset,
  loadMoreAction,
}: PropsWithChildren<{
  initialOffset: T;
  loadMoreAction: loadMoreAction<T>;
}>) => {
  const ref = useRef<HTMLButtonElement>(null);
  const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
 
  const [disVisible, setDisVisible] = useState(false);
  const currentOffsetRef = useRef<number | string | undefined>(initialOffset);
  const [scrollLoad, setScrollLoad] = useState(true);
  const [loading, setLoading] = useState(false);
 
  const loadMore = useCallback(
    async (abortController?: AbortController) => {
      setLoading(true);
 
      // @ts-expect-error Can't yet figure out how to type this
      loadMoreAction(currentOffsetRef.current)
        .then(([node, next]) => {
          if (abortController?.signal.aborted) return;
 
          setLoadMoreNodes((prev) => [...prev, node]);
          if (next === null) {
            currentOffsetRef.current ??= undefined;
            setDisVisible(true);
            return;
          }
 
          currentOffsetRef.current = next;
        })
        .catch(() => {})
        .finally(() => setLoading(false));
    },
    [loadMoreAction],
  );
 
  useEffect(() => {
    const signal = new AbortController();
 
    const element = ref.current;
 
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && element?.disabled === false) {
        loadMore(signal);
      }
    });
 
    if (element && scrollLoad) {
      observer.observe(element);
    }
 
    return () => {
      signal.abort();
      if (element) {
        observer.unobserve(element);
      }
    };
  }, [loadMore, scrollLoad]);
 
  return (
    <>
      <div className="fixed container top-4 z-50 flex justify-end">
        <Label htmlFor="scrollLoad" className="cursor-pointer">
          <Card className="w-max flex p-4 gap-4 items-center m-2">
            <Switch
              id="scrollLoad"
              onCheckedChange={() => setScrollLoad((prev) => !prev)}
              checked={scrollLoad}
            ></Switch>
            <span>Fetch on scroll</span>
          </Card>
        </Label>
      </div>
      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 pt-12 relative">
        {children}
        {loadMoreNodes}
      </div>
 
      {!disVisible && (
        <Button variant="outline" size="lg" ref={ref} onClick={() => loadMore()}>
          {loading ? 'Loading...' : 'Load More'}
        </Button>
      )}
    </>
  );
};
 
export default LoadMore;

コードの説明をします。

  • loadMoreActionは、offsetを受け取って、Promiseを返す関数です。Promiseは、JSX.Elementと、次のオフセットを返します。もし、次のオフセットがない場合は、nullを返します。
  • LoadMoreコンポーネントは、childrenを表示し、loadMoreActionを実行するボタンを表示するコンポーネントです。
  • loadMore関数は、loadMoreActionを実行します。loadMoreActionは、Promiseを返すので、Promiseが完了するまで、loadingtrueにします。Promiseが完了したら、loadingfalseにします。Promiseが完了したら、loadMoreActionの結果をloadMoreNodesに追加します。もし、次のオフセットがない場合は、disVisibletrueにします。
  • scrollLoadtrueの場合は、IntersectionObserverを使って、loadMore関数を実行します。scrollLoadfalseの場合は、loadMore関数は実行されません。
  • scrollLoadを切り替えるためのSwitchコンポーネントを表示します。

まとめ

以上になります。 一般的に無限スクロールはSWRReact Queryを使って CC で実装すると思いますが、SC でも実装できるのではないかと思い、Server Actionsで実装してみました。 参考になれば幸いです。

参考