For

2023.7.9

スクロールで記事を追加フェッチするUIの設計をApp Routerで考える


概要

ブログの記事一覧ページやECサイトの商品一覧ページなどでときどき見かける、スクロールすると追加で記事や商品を表示していくUIについて、Next.js13.4でstableとなったApp Routerでの実装を考えていたことと、実装してみた内容を紹介します。

仕様

Fetchされるタイミング

追加で記事を取得するということは、まず最初に思い浮かぶのはユーザーの操作(スクロール)によるクライアントサイドでの処理になります。
他の要素も含めて検討していきます。

microCMSのプラン

このブログはNext.jsとmicroCMSで構築していますが、microCMSではhobbyプランを利用しているので、ひと月あたりのデータ通信量にリミットがあります。
ユーザーがアクセスし、スクロールすることで追加で10件とか記事を取得するようなリクエストが飛ぶ実装の場合、アクセス数が増えてきたときに問題になります。
その点についてもできれば回避したいところです。

UX的な観点

そもそもの話として、ページを表示した時点で全記事が一覧で表示されていれば追加のリクエストなど必要なく、ユーザーとしても待ち時間が発生しないのでUX的にはスクロールで追加フェッチすることにあまりメリットはないと考えます。
サムネイルのような画像が紐づいていたりすると、ページ初期表示時点で取得するリソース量が減るというメリットがありますが、このブログはサムネイルは不要と考えているので初期表示時点の取得するリソース量は取得記事数に関わらずあまり変わらないです。
その上で、スクロールで記事を追加で表示していくのは割りとエンジニア側のエゴということになります。

しかし!このブログでは多くのユーザーに対して最適な実装をしていくというよりは、唯一の自分のエゴを見せていく場所にしているつもりなので、好きな見せ方である追加フェッチという実装をしていきます!

確定仕様

ということで、以下のような仕様にしました。

・記事はサーバーサイドで全記事取得しておく
・クライアントサイドでは取得済みの全記事を最新のものから10件表示し、スクロールに応じて1秒間のローディング中コンポーネントを表示したのち、次の10件を追加で表示する

こうすることで、見せ方はやりたかったそのものにし、クライアントサイドでは記事取得のリクエストが発生しなくなります。
App Routerでは、Server Componentで取得した全記事を、追加で記事を表示していくClient Componentに渡しておき、クライアントサイドではそれをスクロールに応じて小出しにしていきます。

実装内容

以下のように実装してみました。
まだ最適ではないかもしれませんが、上記の仕様で実装してあります。

tsx_____PseudoAdditionalFetchedArticles.tsx_____'use client';


import { useEffect, useRef, useState } from 'react';
import { ArticleCardInLoading } from '../Card/ArticleCardInLoading';
import { ArticleCard } from '../Card/ArticleCard';
import { ArticleType } from '@/types/ArticleTypes';

type Props = {
  articles: ArticleType[];
};

export default function PseudoAdditionalFetchedArticles({ articles }: Props) {
  const [renderingArticles, setRenderingArticles] = useState<ArticleType[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const observedElementRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      async ([{ isIntersecting }]) => {
        setIsLoading(isIntersecting);
        if (isIntersecting) {
          setTimeout(() => {
            setRenderingArticles((state) => [
              ...state,
              ...articles.slice(state.length, state.length + 10),
            ]);
            setIsLoading(false);
          }, 1000);
        }
      },
      {
        root: null,
        rootMargin: '0px',
        threshold: 0.1,
      }
    );

    const { current } = observedElementRef;
    if (current) observer.observe(current);

    return () => {
      if (current) observer.unobserve(current);
    };
  }, []);

  if (!articles.length) return null;
  return (
    <>
      {renderingArticles.map(({ id, title, publishedAt, categories }) => (
        <li key={id} className={'mt-15 md:mt-30 md:w-[calc(50%-15px)]'}>
          <ArticleCard
            unevenness="bumps"
            shadowColor="default"
            data={{
              title,
              date: publishedAt,
              href: `/articles/${id}`,
              categories: categories.map(({ name }) => name),
            }}
          />
        </li>
      ))}

      <li ref={observedElementRef} className="w-100p" />

      {isLoading && articles.length !== renderingArticles.length && (
        <>
          <li className="mt-15 md:mt-30 md:w-[calc(50%-15px)]">
            <ArticleCardInLoading />
          </li>
          <li className="mt-15 md:mt-30 md:w-[calc(50%-15px)]">
            <ArticleCardInLoading />
          </li>
        </>
      )}
    </>
  );
}


まとめ

今回の実装方法はエゴによるものなのでおすすめはしませんが、クライアントサイドでリクエストを発生させずに追加で記事をフェッチしているような挙動を実装しました。
やりたい内容に応じて実装してみてください。