📜

Server Actions を使用した無限スクロール

2023/10/30に公開

はじめに 🚩

このアイデアは以下の YouTube のライブ配信を視聴中、

https://www.youtube.com/live/WHMm6w41_WI?si=1zLdNRTFog4yEuyC

その中で Server Actions を使用して JSX を返すことが可能 という情報を知り(55分辺り)、この情報に触発され、Server Actions を活用して無限スクロールの実装ができないか、試してみることにしました。(もう三ヶ月も前なんですね...)

本記事で説明する実装に関する動作は以下の X の埋め込みからご覧いただけます。

実装例 📝

JsonPlaceholder でのデータ呼び出し

まずデータの取得が必要となるので、この例では、公開されている fake API の JsonPlaceholder を使用してデータを取得します。

以下は、JsonPlaceholderから投稿データを取得するための関数の例です。

const PAGE_SIZE = 10;

type PostType = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

const getPosts = async (offset: number = 0) => {
  const json = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_start=${offset}&_limit=${PAGE_SIZE}`
  ).then((res) => res.json());

  return json as PostType[] | [];
};

この関数は、指定されたオフセットから10件の投稿データを取得します。PAGE_SIZEを使用して、一度に取得するデータの件数を制御しています。このようにして、無限スクロールの際に次のデータセットを取得するための基盤を作成します。

Server Actions の実装

以下は、Server Actionsを使用して、更に投稿データをロードするための関数の例です。

async function loadMorePost(offset: number = 0) {
  'use server';
  const posts = await getPosts(offset);

  const nextOffset = posts.length >= PAGE_SIZE ? offset + PAGE_SIZE : null;

  return [
    posts.map((post: PostType) => <PostCard key={post.id} post={post} />),
    nextOffset,
  ] as const;
}

この関数 loadMorePost は、指定されたオフセットから次のデータセットを取得し、それをPostCardコンポーネントとしてマッピングします。また、次のオフセットも計算して返しています。これにより、無限スクロールの際に次のデータセットを取得するための情報も提供されます。

page.tsx での実装

次に、メインページの実装を行います。このページでは、初めに投稿データを取得し、それを表示するとともに、さらにデータをロードするための機能を提供します。この機能の核心部分として、LoadMore コンポーネントが使用されています。このコンポーネントの詳細については、次のセクションで説明します。

page.tsx
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 with Server Actions
      </h1>

      <div className='flex flex-col gap-4 items-center'>
        <LoadMore loadMoreAction={loadMorePost} initialOffset={PAGE_SIZE}>
          <PostList posts={initialPosts} />
        </LoadMore>
      </div>
    </main>
  );
}
PostList, PostCard コンポーネントの実装

投稿のリストと各投稿を表示するためのコンポーネントを実装します。ここでは、shadcn/ui を使用してコンポーネントを構築しています。

PostList
const PostList = async ({ posts }: { posts: PostType[] }) => {
  return (
    <>
      {posts?.map((post: PostType) => (
        <PostCard key={post.id} post={post} />
      ))}
    </>
  );
};
PostCard
const PostCard = ({ post }: { post: PostType }) => {
  return (
    <Card key={post.id}>
      <CardHeader>
        <CardTitle className='truncate'>{post.title}</CardTitle>
        <CardDescription>{post.body}</CardDescription>
      </CardHeader>
    </Card>
  );
};

LoadMore コンポーネントについて

このコンポーネントは、指定されたオフセットから新しいデータを取得し、それを表示するための機能を提供します。また、データの取得中にはスピナーを表示して、ユーザーにデータのロード中であることを知らせます。

LoadMore.tsx 全コード
LoadMore.tsx
'use client';

import {
  PropsWithChildren,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';

import { useToast } from './ui/use-toast';
import { Spinner } from './Spinner';

type LoadMoreAction = (
  offset: number
) => Promise<readonly [JSX.Element[], number | null]>;

const LoadMore = ({
  children,
  initialOffset,
  loadMoreAction,
}: PropsWithChildren<{
  initialOffset: number;
  loadMoreAction: LoadMoreAction;
}>) => {
  const ref = useRef<HTMLButtonElement>(null);
  const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
  const [loading, setLoading] = useState(false);
  const [allDataLoaded, setAllDataLoaded] = useState(false);

  // 現在のオフセット
  const currentOffsetRef = useRef<number | undefined>(initialOffset);

  const { toast } = useToast();

  // 新しいデータを取得する関数
  const loadMore = useCallback(
    async (abortController?: AbortController) => {
      setLoading(true);

      setTimeout(async () => {
        // 重複データの取得を防ぐためのチェック
        if (currentOffsetRef.current === undefined) {
          setLoading(false);
          return;
        }

        loadMoreAction(currentOffsetRef.current)
          .then(([node, next]) => {
            // リクエストが中断された場合は早期リターン
            if (abortController?.signal.aborted) return;

            // 全てのデータを取得したかどうかのチェック
            if (node.length < 10) {
              setAllDataLoaded(true);
            }

            // 新しいデータを追加する
            setLoadMoreNodes((prev) => [...prev, ...node]);
            if (next === null) {
              currentOffsetRef.current = undefined;
              return;
            }

            currentOffsetRef.current = next;
          })
          .catch((e) => {
            console.log(e);

            toast({
              variant: 'destructive',
              title: 'エラーが発生しました🥲',
            });
          })
          .finally(() => setLoading(false));
      }, 800);
    },
    [loadMoreAction, toast]
  );

  useEffect(() => {
    // オブザーバーを使用して、スピナーが表示されたときに新しいデータを取得する
    const abortController = new AbortController();

    const element = ref.current;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && element?.disabled === false) {
        loadMore(abortController);
      }
    });

    if (element) {
      observer.observe(element);
    }

    return () => {
      abortController.abort();
      if (element) {
        observer.unobserve(element);
      }
    };
  }, [loadMore]);

  return (
    <>
      <ul className='grid grid-cols-2 gap-4 p-4'>
        {children}
        {loadMoreNodes}
      </ul>
      {!allDataLoaded && (
        <button className='w-full flex justify-center items-center' ref={ref}>
          {loading && <Spinner size={'lg'} className='self-center' />}
        </button>
      )}
    </>
  );
};

export default LoadMore;

型定義

type LoadMoreAction = (
  offset: number
) => Promise<readonly [JSX.Element[], number | null]>;

loadMoreAction は、指定されたオフセットから新しいデータを取得する関数の型を定義しています。この関数は、取得したデータのJSX要素の配列と次のオフセット(またはデータがない場合はnull)を返すPromiseを返す必要があります。

状態の管理

const ref = useRef<HTMLButtonElement>(null);
const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
const [loading, setLoading] = useState(false);
const currentOffsetRef = useRef<number | undefined>(initialOffset);
変数名 説明
ref スクロールの最下部に位置するボタン要素への参照を保持します。この参照を使用して、ユーザーがページの最下部に到達したときに新しいデータの取得をトリガーするためのIntersection Observerを設定します。
loadMoreNodes 新しく取得したデータを表示するためのJSX要素の配列を保持する状態です。新しいデータが取得されるたびに、この配列に新しい要素が追加されます。
loading 現在データの取得中かどうかを示す状態です。データの取得中はtrue、取得が完了したらfalseになります。この状態を使用して、データ取得中にスピナーを表示するかどうかを制御します。
currentOffsetRef 次に取得するデータのオフセットを保持する参照です。データの取得が完了するたびに、このオフセットは更新されます。

新しいデータの取得

const loadMore = useCallback(
    async (abortController?: AbortController) => {
      setLoading(true);

      setTimeout(async () => {
        if (currentOffsetRef.current === undefined) {
          setLoading(false);
          return;
        }
	
     // 指定されたオフセットから新しいデータを取得
        loadMoreAction(currentOffsetRef.current)
          .then(([node, next]) => {
	    // リクエストが中断された場合、処理を中止
            if (abortController?.signal.aborted) return;
	    
	    // 新しい JSX を追加
            setLoadMoreNodes((prev) => [...prev, ...node]);
	    
	    // 次のオフセットが null の場合、これ以上のデータ取得は不要と判断
            if (next === null) {
              currentOffsetRef.current = undefined;
              return;
            }
	    
            // 次のオフセットを設定
            currentOffsetRef.current = next;
          })
          .catch((e) => {
            console.log(e);
            toast({
              variant: 'destructive',
              title: 'エラーが発生しました🥲',
            });
          })
          .finally(() => setLoading(false));
      }, 800);
    },
    [loadMoreAction, toast]
);

loadMore 関数は、新しいデータを取得するための主要な関数です。指定されたオフセットからデータを取得し、それをloadMoreNodes状態に追加します。また、データの取得中は loading 状態を true に設定して、スピナーを表示します。

スクロールの監視

useEffect(() => {
    const abortController = new AbortController();
    // スクロールの最下部に位置するボタン要素への参照
    const element = ref.current;
    
    // IntersectionObserver を使用して、ボタン要素が画面内に表示されたかを監視
    const observer = new IntersectionObserver(([entry]) => {
      // ボタン要素が画面内に表示され、かつボタンが無効化されていない場合
      if (entry.isIntersecting && element?.disabled === false) {
        // 新しいデータを取得する関数を呼び出し
        loadMore(abortController);
      }
    });
    
    // ボタン要素が存在する場合、その要素を監視対象として追加
    if (element) {
      observer.observe(element);
    }

    return () => {
      // 進行中のリクエストを中止
      abortController.abort();
      
      // ボタン要素の監視を終了
      if (element) {
        observer.unobserve(element);
      }
    };
  }, [loadMore]);

useEffect フックを使用して、スクロールの監視を行います。ユーザーがページの最下部に到達した際に、loadMore 関数を呼び出して新しいデータを取得します。

レンダリング

return (
    <>
      <ul className='grid grid-cols-2 gap-4 p-4'>
        {children}
        {loadMoreNodes}
      </ul>
      <button className='w-full flex justify-center items-center' ref={ref}>
        {loading && <Spinner size={'lg'} className='self-center' />}
      </button>
    </>
);

最後に、取得したデータとスピナーを表示します。データの取得中は、スピナーが表示され、データが取得されたらそのデータが表示されます。

全データ取得後のスピナーの停止

現在の実装では、全てのデータが取得された後も、画面の最下部に到達するたびにスピナーが表示されてしまいます。以下のコード追加で、全てのデータが取得された場合にはスピナーを表示しないように修正します。

LoadMore.tsx
'use client';
type loadMoreAction = (
  offset: number
) => Promise<readonly [JSX.Element[], number | null]>;

const LoadMore = ({
  children,
  initialOffset,
  loadMoreAction,
}: PropsWithChildren<{
  initialOffset: number;
  loadMoreAction: loadMoreAction;
}>) => {
  const ref = useRef<HTMLButtonElement>(null);
  const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
  const [loading, setLoading] = useState(false);
+ const [allDataLoaded, setAllDataLoaded] = useState(false);

  // 現在のオフセット
  const currentOffsetRef = useRef<number | undefined>(initialOffset);

  const { toast } = useToast();

  // 新しいデータを取得する関数
  const loadMore = useCallback(
    async (abortController?: AbortController) => {
      setLoading(true);

      setTimeout(async () => {
        // 重複データの取得を防ぐためのチェック
        if (currentOffsetRef.current === undefined) {
          setLoading(false);
          return;
        }

        loadMoreAction(currentOffsetRef.current)
          .then(([node, next]) => {
            // リクエストが中断された場合は早期リターン
            if (abortController?.signal.aborted) return;

+           if (node.length < PAGE_SIZE) {
+             setAllDataLoaded(true);
+           }

            // 新しいデータを追加する
            setLoadMoreNodes((prev) => [...prev, ...node]);
            if (next === null) {
              currentOffsetRef.current = undefined;
              return;
            }

            currentOffsetRef.current = next;
          })
          .catch((e) => {
            console.log(e);

            toast({
              variant: 'destructive',
              title: 'エラーが発生しました🥲',
            });
          })
          .finally(() => setLoading(false));
      }, 800);
    },
    [loadMoreAction, toast]
  );

  useEffect(() => {
    // オブザーバーを使用して、スピナーが表示されたときに新しいデータを取得する
    const abortController = new AbortController();

    const element = ref.current;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && element?.disabled === false) {
        loadMore(abortController);
      }
    });

    if (element) {
      observer.observe(element);
    }

    return () => {
      abortController.abort();
      if (element) {
        observer.unobserve(element);
      }
    };
  }, [loadMore]);

  return (
    <>
      <ul className='grid grid-cols-2 gap-4 p-4'>
        {children}
        {loadMoreNodes}
      </ul>
+     {!allDataLoaded && (
        <button className='w-full flex justify-center items-center' ref={ref}>
          {loading && <Spinner size={'lg'} className='self-center' />}
        </button>
+     )}
    </>
  );
};

export default LoadMore;

まとめ 📌

この記事では、Next.js の Server Actions を利用して、無限スクロールの機能を実装する方法について解説しました。
筆者としてはこの方法を提案しましたが、より良い方法や改善点、指摘、別のアプローチがあれば、ぜひフィードバックお願いします🙇‍♂️

以上です!

Discussion