next.js無限スクロール実装してみた。コンソールエラーが出ないように実装するのは可能なのか?

覚えておきたい

2025/07/25 -最終更新日:2025/07/30

ポイント1

クライアントコンポーネント必須。ただし全てクライアント子コンポーネントで取得→表示だとhydrationエラーになるので、親コンポーネントで普通に取ってからクライアント子コンポーネントにわたす。

ポイント2

サーバーコンポーネントで以下を定義して、クライアント子コンポーネントに渡して使用する

async function fetchBlogs(limit: number, offset: number, category?: CategoryType) {
 "use server";
 const data = category ? await getBlogsFromCategory(category, limit, offset) : await getBlogs(limit, offset);
 return data;
}

これだと一見問題なく動作するし、多くの例がある。

ただし、クライアントコンポーネントでサーバーアクションを呼び出すとエラーになる。

ChatGPT参考以下

実際には Client → Server 間で不透明なバインディングが発生(Next.js 内部の魔法)

非同期で状態管理やキャッシュ制御が絡むと破綻しやすい

今後の Next.js のアップデートで壊れる可能性がある

といった問題があります。とのこと。(ほんとか?)

Next.js公式でクライアントコンポーネントからサーバーアクションを読んでいました。大丈夫っぽい

https://nextjsjp.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

ポイント3

server actionsの関数を廃棄し、api/blog/route.tsに色々書いて、それをクライアントコンポーネントで使う

api/blog/route.ts
export async function GET(request: Request) {
 const { searchParams } = new URL(request.url);
 const limit = Number(searchParams.get("limit") ?? 10);
 const offset = Number(searchParams.get("offset") ?? 0);

 // 必要に応じてカテゴリも取得
 const category = searchParams.get("category") as CategoryType;
 const blogs = category ? await getBlogsFromCategory(category, limit, offset) : await getBlogs(limit, offset);
 return NextResponse.json(blogs);
}
clientConponent.tsx
// loadMore関数内
const nextOffset = blogs.length;
const res = await fetch(`/api/blog?offset=${nextOffset}&limit=${LIMIT}`);
if (!res.ok) {
 setLoading(false);
 return;
}
const newBlogs: BlogType[] = await res.json();

hydrationエラー消えない。

ポイント1の、初期表示をクライアントに渡すという前提が間違っているのかもしれない

結論

「無限スクロール」かつ、「記事ページへ遷移して戻っても記事数とスクロール位置が変わらない」ように、エラーが消えるように色々やってみたけど、記事数をsessionStrage/localStrageで取得すると、サーバー側で取っている初期の10件と相違があるので必ずhydrationのエラーがでるんじゃない。

結果的には、server actionsを使うやり方も、api routeでデータを取るやり方も見た目や動作は問題なく作れた。

Lighthouseの点数もパフォーマンスも誤差程度しか変わらなかった。

7/29追記:

サーバーからクライアントに渡していた初期表示をやめ、

apirouteを使うをやめ、

ページが読み込まれた直後は空配列。useEffectでセッションストレージがある場合・ない場合で初期データを読む。

また、一瞬空になる関係でスクロール位置がリセットされる問題をこちらもセッションストレージで解決。

エラーは仕方ないかなと思いながら開き直って上記直したら、本番環境のエラーがなくなっていました。

ハイドレーション気にして最初に親のサーバーコンポーネントでデータ取ってから他はクライアントコンポーネントでデータ取るみたいなことしてたのがだめだったかな。

export default function BlogListClient({ category, fetchBlogs }: Props) {
 const [blogs, setBlogs] = useState<BlogType[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef<HTMLDivElement | null>(null);
// blog配列が最初空のため、すぐにobserverが動作してしまうため、isInitialLoadを使用して、最初のロードを防ぐ
const [isInitialLoad, setIsInitialLoad] = useState(true);
const initialLoadRef = useRef(true);

/**
 * セッションストレージからの初期データ読み込み
 */
useEffect(() => {
let savedBlogs: string | null = null;
if (category) {
savedBlogs = sessionStorage.getItem(category);
 } else {
savedBlogs = sessionStorage.getItem(STORAGE_KEY_BLOGS);
 }
if (savedBlogs) {
setBlogs(JSON.parse(savedBlogs));
 } else {
loadMore();
 }
setIsInitialLoad(false);
 }, [category]);

/**
 * セッションストレージへの保存
 */
useEffect(() => {
if (blogs.length > 0) {
const uniqueBlogs = Array.from(new Map(blogs.map((blog) => [blog.id, blog])).values());
if (category) {
sessionStorage.setItem(category, JSON.stringify(uniqueBlogs));
 } else {
sessionStorage.setItem(STORAGE_KEY_BLOGS, JSON.stringify(uniqueBlogs));
 }
 }
 }, [blogs, category]);

/**
 * 無限スクロール
 */
const loadMore = useCallback(async () => {
if (!initialLoadRef.current) return;
if (loading || !hasMore) return;
setLoading(true);
const nextOffset = blogs.length;
const newBlogs = await fetchBlogs(LIMIT, nextOffset, category);
if (newBlogs.length < LIMIT) setHasMore(false);

setBlogs((prev) => {
const allBlogs = [...prev, ...newBlogs];
const uniqueBlogs = Array.from(new Map(allBlogs.map((blog) => [blog.id, blog])).values());
return uniqueBlogs;
 });
setLoading(false);
 }, [loading, hasMore, blogs, category, fetchBlogs]);

// observer
useEffect(() => {
if (!hasMore) return;

const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
if (isInitialLoad) return;
loadMore();
 }
 });
if (observerRef.current) {
observer.observe(observerRef.current);
 }
return () => {
if (observerRef.current) observer.unobserve(observerRef.current);
 }
 };
 }, [hasMore, loadMore, isInitialLoad]);

/**
 * スクロール位置の復元(コンポーネントマウント時)
 */
useEffect(() => {
// 保存されたスクロール位置を復元
const savedScrollPosition = sessionStorage.getItem(category ? ${category}-scrollPosition : "scrollPosition");
if (savedScrollPosition && parseInt(savedScrollPosition) > 0) {
setTimeout(() => {
window.scrollTo(0, parseInt(savedScrollPosition));
 }, 100);
 }
 }, []);

/**
 * スクロール位置の保存と復元
 */
useEffect(() => {
// スクロール位置を取得する関数
const getScrollPosition = () => {
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || window.pageYOffset || 0;
 };

// スクロールイベントでリアルタイム保存
const handleScroll = () => {
const currentScrollPosition = getScrollPosition();
if (currentScrollPosition > 0) {
sessionStorage.setItem(category ? ${category}-scrollPosition : "scrollPosition", currentScrollPosition.toString());
 }
 };
window.addEventListener("scroll", handleScroll);

// クリーンアップ
return () => {
window.removeEventListener("scroll", handleScroll);
 };
 }, []);

return (
<>
<ul>
{blogs.map((blog) => (
<BlogItem key={blog.id} blogData={blog} />
 ))}
</ul>
{hasMore && <div ref={observerRef} className="h-px" />}
{loading && <Spinner />}
</>
 );
}