Claude Codeで無限スクロールを本番実装する方法
Claude CodeでReactの無限スクロールを作る手順。Intersection Observer、カーソルAPI、失敗例まで解説。
無限スクロールは、読者がページ下部へ近づいたときに次のデータを自動で読み込むUIです。SNS、記事一覧、ECの商品一覧、管理画面のログビューアでは自然に見えますが、実装は「最後の要素が見えたらfetchする」だけでは終わりません。
実務で壊れやすいのは、重複読み込み、戻るボタンで位置が失われる問題、検索エンジンやスクリーンリーダーへの配慮、APIのページング方式、そして失敗時の復旧導線です。Claude Codeに「無限スクロールを作って」とだけ依頼すると、見た目は動いても本番で詰まるコードになりがちです。
この記事では、Claude Codeに渡す要件、Reactの実装、Next.jsのカーソルAPI、3つ以上のユースケース、具体的な落とし穴、検証メモまでまとめます。無限スクロールと似ている大量DOM対策は仮想スクロール実装を、ページ番号で区切る設計はページネーション実装もあわせて読んでください。
最初に決める設計
まず用語をそろえます。Intersection Observerは、画面内に要素が入ったかをブラウザが非同期に教えてくれるAPIです。スクロールイベントを毎回監視するより軽く、MDNのIntersection Observer APIでも、ターゲット要素とviewportの交差を監視するためのAPIとして説明されています。
無限スクロールでは、最後に置く小さな監視要素を「sentinel」と呼ぶことがあります。ここでは「見張り要素」と考えれば十分です。この要素が見えたら次のページを読み込みます。
もう1つ重要なのがカーソルページングです。offsetページングは「20件飛ばして次の20件」のように数で指定します。データが追加・削除される一覧では、同じ記事が二重に出たり、1件飛ばされたりします。カーソルページングは「このidの次から」のように最後に読んだ位置を渡すため、時系列のフィードでは安定します。
Claude Codeには、次のように依頼します。
ReactとNext.jsで記事一覧の無限スクロールを実装してください。
Intersection Observerを使い、APIはcursorベースにしてください。
重複fetch防止、AbortController、エラー表示、手動の「もっと読む」ボタン、
aria-live、role="feed"、SEO用の通常リンクを残す方針も含めてください。
既存のfrontmatter、heroImage、内部リンクは触らないでください。
Claude Codeの公式ワークフローでも、広い依頼から始めて対象を絞り、再現手順や制約を伝える流れが紹介されています。無限スクロールも同じで、UIだけではなくAPI、状態管理、アクセシビリティ、検証コマンドまで先に渡すほど差分が安定します。
使うべきユースケース
1つ目は記事メディアの一覧です。ClaudeCodeLabのように記事数が増えるサイトでは、初期表示を軽くしつつ、読者が興味を持ったら次の記事を自然に出せます。ただし、記事詳細に飛んで戻ったときに位置を復元できないと読者は離脱します。
2つ目はECやSaaSの検索結果です。商品やテンプレートを眺める体験では、ページ番号より無限スクロールのほうが探しやすいことがあります。一方で絞り込み条件、並び順、URLクエリを維持しないと、共有や再訪が難しくなります。
3つ目は管理画面の監査ログや通知一覧です。担当者は最新順に流し読みしたいので相性がよいです。ただし監査ログは「どこまで確認したか」が重要なので、カーソル、時刻、既読状態を明確に分けて設計します。
4つ目はチャット、コメント、アクティビティフィードです。ここでは「下へ進む無限スクロール」だけでなく、過去ログを上へ読み込む逆方向のパターンもあります。Claude Codeへ依頼するときは、読み込み方向を必ず明記してください。
Reactフックの実装
次のフックは、カーソルAPIを前提にした最小の本番寄り実装です。AbortControllerでアンマウント時の古い通信を止め、loadingRefで二重fetchを抑え、rootMarginで見張り要素が画面に入る少し前に読み込みます。
import { useCallback, useEffect, useRef, useState } from "react";
export type CursorPage<T> = {
items: T[];
nextCursor: string | null;
};
type FetchPage<T> = (args: {
cursor: string | null;
signal: AbortSignal;
}) => Promise<CursorPage<T>>;
type InfiniteStatus = "idle" | "loading" | "error" | "done";
type UseInfiniteCursorOptions<T> = {
fetchPage: FetchPage<T>;
mergeItems?: (previous: T[], next: T[]) => T[];
initialCursor?: string | null;
};
export function useInfiniteCursor<T>({
fetchPage,
mergeItems,
initialCursor = null,
}: UseInfiniteCursorOptions<T>) {
const [items, setItems] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [status, setStatus] = useState<InfiniteStatus>("idle");
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef(false);
const hasMore = cursor !== null || items.length === 0;
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setStatus("loading");
setError(null);
try {
const page = await fetchPage({ cursor, signal: controller.signal });
setItems((previous) =>
mergeItems ? mergeItems(previous, page.items) : [...previous, ...page.items],
);
setCursor(page.nextCursor);
setStatus(page.nextCursor ? "idle" : "done");
} catch (unknownError) {
if (unknownError instanceof DOMException && unknownError.name === "AbortError") {
return;
}
setError(unknownError instanceof Error ? unknownError : new Error("Load failed"));
setStatus("error");
} finally {
loadingRef.current = false;
}
}, [cursor, fetchPage, hasMore, mergeItems]);
const sentinelRef = useCallback(
(node: HTMLElement | null) => {
observerRef.current?.disconnect();
if (!node || !hasMore) return;
observerRef.current = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) void loadMore();
},
{ rootMargin: "600px 0px", threshold: 0 },
);
observerRef.current.observe(node);
},
[hasMore, loadMore],
);
useEffect(() => {
void loadMore();
return () => {
abortRef.current?.abort();
observerRef.current?.disconnect();
};
}, [loadMore]);
return {
items,
status,
error,
hasMore,
loadMore,
sentinelRef,
};
}
useEffectと外部システムの同期についてはReact公式のuseEffectリファレンスが基準になります。Claude Codeに修正を依頼するときも、「Effectのcleanupでobserverとfetchを止める」と明記すると、メモリリークや古いレスポンスの混入を減らせます。
一覧コンポーネント
次は記事一覧側です。自動読み込みが失敗しても、同じloadMoreを手動ボタンで呼べるようにしています。これはアクセシビリティと障害復旧の両方で効きます。
import { useCallback } from "react";
import { useInfiniteCursor, type CursorPage } from "./useInfiniteCursor";
type Article = {
id: string;
title: string;
summary: string;
href: string;
publishedAt: string;
};
function mergeUniqueById(previous: Article[], next: Article[]) {
const seen = new Set(previous.map((item) => item.id));
return [...previous, ...next.filter((item) => !seen.has(item.id))];
}
async function fetchArticlePage({
cursor,
signal,
}: {
cursor: string | null;
signal: AbortSignal;
}): Promise<CursorPage<Article>> {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const response = await fetch(`/api/articles?${params}`, { signal });
if (!response.ok) throw new Error(`Failed to load articles: ${response.status}`);
return response.json();
}
export function ArticleFeed() {
const fetchPage = useCallback(fetchArticlePage, []);
const { items, status, error, hasMore, loadMore, sentinelRef } = useInfiniteCursor({
fetchPage,
mergeItems: mergeUniqueById,
});
return (
<section aria-labelledby="article-feed-title">
<h2 id="article-feed-title">Latest articles</h2>
<div role="feed" aria-busy={status === "loading"}>
{items.map((article, index) => (
<article
key={article.id}
role="article"
aria-posinset={index + 1}
aria-setsize={hasMore ? -1 : items.length}
>
<a href={article.href}>
<h3>{article.title}</h3>
</a>
<p>{article.summary}</p>
<time dateTime={article.publishedAt}>
{new Intl.DateTimeFormat("ja-JP").format(new Date(article.publishedAt))}
</time>
</article>
))}
</div>
{error && (
<p role="alert">
読み込みに失敗しました。通信状態を確認して、もう一度試してください。
</p>
)}
<div ref={sentinelRef} aria-hidden="true" />
<p aria-live="polite">
{status === "loading" && "読み込み中です。"}
{status === "done" && "すべての記事を表示しました。"}
</p>
{hasMore && (
<button type="button" onClick={() => void loadMore()} disabled={status === "loading"}>
もっと読む
</button>
)}
</section>
);
}
role="feed"を使うなら、WAI-ARIA Authoring Practicesのfeed patternも確認してください。すべてのサイトで必須ではありませんが、読み上げ順、現在位置、読み込み状態を説明する設計に役立ちます。
Next.jsのカーソルAPI
フロントだけを作っても、APIがoffsetのままだと重複や欠落が出ます。Next.js App Routerなら、次のようにlimit + 1件を取得し、余分な1件で次ページの有無を判定します。
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? "20"), 1), 50);
const cursor = searchParams.get("cursor");
const rows = await prisma.article.findMany({
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
select: {
id: true,
title: true,
summary: true,
href: true,
publishedAt: true,
},
});
const items = rows.slice(0, limit);
const nextCursor = rows.length > limit ? items.at(-1)?.id ?? null : null;
return NextResponse.json({ items, nextCursor });
}
本番では、publishedAtとidの組み合わせに合わせたインデックスも確認します。ここを曖昧にすると、データが増えた瞬間にAPIが遅くなります。パフォーマンス最適化で扱う計測と同じで、UIの滑らかさはDBクエリの安定性に依存します。
失敗例と落とし穴
最初の落とし穴は、Observerが何度も発火して同じページを連続で取りに行くことです。loadingをstateだけで見ていると、レンダーのタイミングによって間に合わないことがあります。上の実装ではloadingRefで即時にロックしています。
次の落とし穴は、offsetページングをフィードに使うことです。新しい記事が先頭に追加されると、2ページ目の内容がずれて重複します。ニュース、通知、ログのように動く一覧ではカーソルを選んでください。
3つ目は、フッターやCTAに到達できなくなることです。無限スクロールが延々と読み込まれると、読者はサイトの会社情報、問い合わせ、Claude Code研修の導線にたどり着けません。一定件数ごとに手動ボタンへ切り替える、またはフッター直前で自動読み込みを止める設計が必要です。
4つ目は、SEOと共有URLを捨てることです。検索エンジンやSNSカードは、ユーザーがスクロールした後の状態を前提にできません。通常のカテゴリページ、ページ番号付きURL、サイトマップ、内部リンクを残す方針をClaude Codeに伝えてください。
5つ目は、戻るボタンの体験です。詳細ページから戻ったら先頭に戻る一覧は、商品探索や記事探索では強いストレスになります。URLクエリにカーソルやフィルタを残す、またはブラウザのscroll restorationとキャッシュを検証します。
Claude Codeにレビューさせる観点
実装後は、Claude Codeに「差分レビュー」ではなく「失敗モードレビュー」を依頼します。たとえば、重複fetch、未cleanupのobserver、AbortErrorの扱い、APIレスポンスの型、aria-live、キーボード操作、limitの上限、DBインデックス、URL共有、テスト不足を明示します。
この無限スクロール実装を本番レビューしてください。
観点は、二重fetch、古いレスポンス混入、IntersectionObserverのcleanup、
AbortError、カーソルページング、アクセシビリティ、SEO、戻るボタン、
DBインデックス、失敗時の手動復旧です。
問題があればファイル名と修正案を出してください。
Claude Code自体の概要はAnthropicのClaude Code overviewを確認できます。エージェントに任せるほど、最初の依頼文とレビュー観点が品質を左右します。
まとめとCTA
無限スクロールは、読者にとっては滑らかなUIですが、作る側にとってはAPI設計、状態管理、アクセシビリティ、SEO、計測が絡む機能です。Claude Codeを使うなら、Intersection Observerだけでなく、カーソルAPI、手動復旧ボタン、戻るボタン、フッター到達、検証観点まで一緒に依頼してください。
チームで同じ品質の実装を増やしたい場合は、Claude Code研修で、プロンプト設計、差分レビュー、テスト、公開前チェックまで一連の流れとして整えるのが近道です。単発のコード生成ではなく、壊れにくい実装手順としてチームに残すことが重要です。
実際に試した結果
この記事の更新では、MDN、React、WAI-ARIA、Anthropic公式ドキュメントを確認し、壊れていた文字化け本文を本番向けの構成に置き換えました。コードはTypeScript/TSXとして構文が通る形に整理し、重複fetch、AbortController、cursor API、手動復旧、aria-liveを1つの流れで確認できるようにしました。実プロジェクトでは、最後にnpm run build、APIの負荷確認、モバイル実機での戻るボタン検証まで行うのが安全です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。