カルキチブログ

ライブラリ不要!Next.jsのApp RouterでServer Actionsを使って無限スクロールを実装する

Next.jsの13.4でServer Actionsという、fetch関数などを利用したAPIと通信を行う処理を記述することなくサーバ上でデータの更新 (作成、更新、削除)を行うことができる機能が実装されました。

この記事を執筆した段階でstableではなく、実験的な機能なのでプロダクトでの採用は慎重に検討する必要がありそうですが、昨今のNext.jsの動向を見ると、App Routerやサーバーコンポーネントを使ったパフォーマンス最適化、Streaming SSRなどサーバーサイドとの関係性がより密接になった機能が実装されつつあるので、Server Actionsもそのうちstableになるんじゃないかなと勝手に思っております。

今回の記事はNext.jsのServer Actionsをデータの更新ではなく、取得にも使えるのでは?という仮説をモチベーションに、無限スクロールをクライアントサイドでのフェッチ処理を行わずに、Server Actionsを使って実装する方法について解説してみます。

動いてるイメージはこんな感じになってます。

実装方法

実際に動くコードはこんな感じになっています。

タイトルにも書いてあるように、React以外のライブラリは使っていません。

import { Items } from './_components/Items';

async function fetchItems(page = 0) {
  'use server';

  return Array.from({ length: 10 }).map((_, index) => ({
    name: `Item #${page * 10 + index + 1}`,
  }));
}

export default async function Page() {
  const items = await fetchItems();

  return <Items initialItems={items} fetchItems={fetchItems} />;
}

データを表示するコンポーネントはこんな感じです。

'use client';

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

type Item = {
  name: string;
};

type Props = {
  initialItems: Item[];
  fetchItems: (page?: number) => Promise<Item[]>;
};

export function Items({ initialItems, fetchItems }: Props) {
  const observerTarget = useRef(null);

  const [items, setItems] = useState([initialItems]);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(0);

  const flatItems = items.flatMap((page) => page);

  const loadMore = useCallback(
    async (page: number) => {
      const data = await fetchItems(page);
      setItems((prev) => [...prev, data]);

      const count = data.length;
      setHasMore(count > 0);
    },
    [fetchItems]
  );

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && hasMore) {
            setPage((p) => p + 1);
          }
        });
      },
      { threshold: 1.0 }
    );

    let observerRefValue: null = null;

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
      observerRefValue = observerTarget.current;
    }

    return () => {
      if (observerRefValue) observer.unobserve(observerRefValue);
    };
  }, [hasMore, observerTarget]);

  useEffect(() => {
    if (page > 0) loadMore(page);
  }, [page, loadMore]);

  return (
    <div>
      {flatItems.map((item) => (
        <div key={item.name}>
          {item.name}
        </div>
      ))}
      <div ref={observerTarget}>{hasMore && <div>Loading ...</Heading>}</div>
    </div>
  );
}

リポジトリも掲載しておきます。

リポジトリ

https://github.com/Yota-K/next-13-playground/tree/main/app/infinity-scroll

実装のポイント

実装のポイントを簡単にまとめてみました。

  • データの取得にServer Actionsを使用する
  • データの表示を行うコンポーネントはクライアントコンポーネントにする
  • スクロールした際の要素を監視する処理には、Intersection Observer APIを使用する

データの取得にServer Actionsを使用している

一番重要なポイントです。

公式のサンプルコードがformを使った更新処理をAPIを生やさなくても実装できるみたいな感じになっていたこともあり、最初は気づかなかったのですが、Next.jsのServer Actionsはform以外で発生した処理もサーバーサイドで実行することができます。

formのactionを使わずにServer Actionsを呼び出すことをCustom Invocationと呼びます。

以下Next.jsの公式ドキュメントの引用です。

Custom Invocation with startTransition: Invoke Server Actions without using action or formAction by using startTransition. This method disables

引用: https://nextjs.org/docs/app/api-reference/functions/server-actions

Custom Invocationを使ってServer Actionsを呼び出すと、以下のようなリクエストが返却されます。

リクエストはPOSTで、 ContentTypeはtext/plainで実行されるようです。

少々話が脱線しますが、form以外でも使用可能である点がRemixのactionやSvelteKitのForm actionsと異なる部分なのかなと考えています。

RemixもSvelteKitも遊びで触ったことあるレベルで、そこまで知見はないのですが、両者ともにデータの作成、更新、削除をAPIを用意せずに、実装することができる機能なのかなと考えています。

Server Actionsを使用してデータの取得を行うと、データの取得はサーバーサイドで実行されます。

データの取得をサーバーサイドで行なっているので、console.log等で仕込んだログは開発用のサーバに出力され、ブラウザでは確認できません。

データの表示を行うコンポーネントはクライアントコンポーネントにする

データの表示を行うコンポーネントでは、以下の処理が必要になります。

  • ユーザがページ内のどこまでスクロールしたかを監視する
  • 監視対象の要素が可視状態になったら、非同期にデータの読み込みを行う
  • 取得できるデータがない場合は「Loading ...」というテキストは非表示にする

上記の処理を実現するためには、スクロールの検知やDOMの参照や状態の保持、Reactの副作用を使用したデータのフェッチを行う必要があるので、クライアントコンポーネントにする必要があります。

どんなコンポーネントがクライアントコンポーネントになるのか考えて設計を行う必要がある点もNext.jsのApp Routerの難しいポイントですが、自分の中では、以下に該当するコンポーネントがクライアントコンポーネントであると考えるようにしています。

  • イベントハンドラを含むコンポーネント
  • windowオブジェクトを参照する処理を含むコンポーネント
  • Reactのフック(useState, useEffectなど)を含むコンポーネント
  • ReduxやSWRなどのライブラリで使用するProviderコンポーネント
  • グローバルステートの取得、操作を行うコンポーネント

こちらに関してもNext.jsの公式ドキュメントで表でまとめられていますね!

スクロールした際の要素を監視する処理には、Intersection Observer APIを使用する

一つ前のセクションで先出ししましたが、スクロールした際の要素の監視を行うためには、Intersection Observer APIというターゲット要素が祖先要素、または文書の最上位のビューポートと交差する変化を非同期的に監視する方法を提供するJavaScriptのAPIを使う必要があります。

簡潔にまとめると、Intersection Observer APIを使用するとスクロール対象の要素を監視して、監視対象の要素に到達したかどうかを検知することができます。

Intersection Observer APIを使用して、監視対象の要素が可視状態になったら、setPageでpageというstateを+1して、データの取得を行うみたいな処理を行なっています。

Intersection Observer APIに関して当ブログで解説記事を執筆したことがあるので、興味がある方は読んでみてください!

まとめ

ではまとめです。

  • Next.jsのServer Actionsはサーバーサイドの機能をfetch関数などを利用したAPIと通信を行う処理を記述することなくサーバ上でデータの更新 (作成、更新、削除)を行うことができる機能。
  • Server Actionsはform以外のGETでも使用できる。
  • データのフェッチ処理をServer Actionsを使用してサーバーサイドで行い、スクロールした際の要素の監視にIntersection Observer APIを使用すれば、無限スクロールを実装することもできる

無限スクロールを過去に実装した時は、クライアントサイドでフェッチする方針(サーバの状態やキャッシュ戦略も管理したかったので、TanstackQueryを使ってた)で実装していたのですが、Next.jsを使用すると、クライアントサイドでフェッチしなくても実装できてしまうということがわかりました。

おそらくですが、この方法を応用すれば、「もっと見る」のようなボタンを押した時にデータを取得して表示するような実装も実現できると思います。

おまけ

久々の更新でした。

最近フリーランスに転向して、時間の余裕もできたので、そろそろアウトプットしたいなと思い書いてみたって感じです。

今回の記事ですが、今までのカルキチブログには多分なかった「この機能ってこういう使い方できるんじゃない?」みたいなモチベーションから生まれた記事です。

実装の参考にした記事はstack overflowにあったのですが、Intersection Observer API使う部分とかはオリジナルのつもりです。

まあ、探したらどっかにはそういう情報あるかもしれないですが、何かの機能の解説とかじゃなくて、仮説・検証からこういうことできるみたいな記事を書けるようになったってのは結構大きな成長なのかなとか思ったりしています。

書きたい記事は溜まりまくってるので、今後はまた月1くらいで書いていけたらなとは思ってます。

参考にした記事

https://stackoverflow.com/questions/76266563/infinite-scroll-with-nextjs13-server-components-app-directory