React Router v7 で 無限スクロールを実装して useFetcher を学ぶ
こんにちは、Gaji-Labo フロントエンドエンジニアの下條です。
Gaji-Labo では、React や Next.js、React Router v7 を得意分野として、スタートアップ支援・プロダクトチーム支援をしています。
React Router では、Web 標準に沿った設計思想を重視しており、画面遷移を伴ってサーバーサイドの処理を実行することが基本です。一方で、画面内でのデータ取得や、楽観的UIの更新など、ユーザビリティを損なわないために画面遷移を行わずにデータ取得や更新をしたい場合に、useFetcher
フックを提供しています。
useFetcher
を使うことで、以下のような利点があります:
- URLを変更せずにサーバーとデータをやり取りできる
- 複数の非同期処理を独立して管理できる
fetcher.state
で読み込み状態を簡単に追跡できる
この記事では、記事一覧を表示する画面で、画面下までスクロールすると追加の記事を読み込む、いわゆる「無限スクロール」の機能を実装し、 useFetcher
の使い方を紹介します。
前提
この記事では以下を前提とします:
- React Router v7.9.3
- データ取得は React Router の
loader
関数内で行うこと
完成イメージ
今回実装する無限スクロールの仕様は以下の通りです:
- 初回アクセス時に20件の記事(Article)を表示
- ページ下部までスクロールすると、次の20件を読み込む
- 読み込み中はローディングインジケーターを表示
- すべてのアイテムを読み込んだら、読み込みの処理を実行しない
完成されたコードはこちらから参照できます。
https://github.com/takumibv/infinite-scroll-demo-app-rrv7
手順
1. React Router プロジェクト作成
React Router の公式ドキュメントに沿ってプロジェクトを作成していきます。
今回は MUI を使用して作成するため、テンプレートから作成します。
curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/material-ui-react-router-ts
cd material-ui-react-router-ts
2. ファイル構成
以下のファイル構成でプロジェクトを作成します:
app/
├── routes
│ ├── api
│ │ └── articles.ts # useFetcher でリクエストする記事取得 API
│ └── home.tsx # 記事一覧画面
├── routes.ts
├── types.ts
└── ...
API で取得するデータの型を定義していきます。
// app/types.ts
export interface Article {
id: number;
title: string;
description: string;
createdAt: string;
}
export interface FetchArticlesResponse {
articles: Article[];
hasMore: boolean;
currentPage: number;
totalCount: number;
}
4. loader の定義
app/routes/home.tsx
を作成し、loader を定義します。loader はルートにアクセスした際に自動的に実行され、データを取得します。
// app/routes/home.tsx
import { useLoaderData } from "react-router";
import { fetchArticles } from "~/utils/mockApi"; // ここではモックを使用してデータを取得します
export async function loader() {
const data: FetchArticlesResponse = await fetchArticles({ page: 1 });
return {
articles: data.articles,
hasMore: data.hasMore,
currentPage: 1,
totalCount: data.totalCount,
};
}
ポイント:
fetchArticles
はデータ取得の処理です。(ここでは本質ではないため省略します。モックの実装例は https://github.com/takumibv/infinite-scroll-demo-app-rrv7/blob/main/app/utils/mockApi.ts を参照)- 初回ロード時は最初のページのデータのみ取得するため、 page は固定で 1 としています。
routes.ts
に作成したファイルをルーティングに設定します。
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
] satisfies RouteConfig;
5. コンポーネントの定義(loaderData を使った表示)
次に、loader から取得したデータを表示するコンポーネントを実装します。
// app/routes/home.tsx に追加
import { useLoaderData } from "react-router";
import {
Typography,
List,
ListItem,
ListItemText,
Container,
} from "@mui/material";
import type { Route } from "./+types/home";
export async function loader() {
// 省略
}
export default function Home({ loaderData }: Route.ComponentProps) {
const {
articles,
hasMore,
} = loaderData;
return (
<Container sx={{ p: 4 }}>
<List sx={{ mt: 2.5 }}>
{articles.map((article) => (
<ListItem key={article.id}>
<ListItemText>
<Typography variant="subtitle1">
{article.title}
</Typography>
<Typography variant="body2">
{article.description}
</Typography>
</ListItemText>
</ListItem>
))}
</List>
{hasMore && (
<p>{/* TODO: スクロールしてデータを読み込む */}</p>
)}
</Container>
);
}
ポイント:
loader
の返り値の型はRoute.ComponentProps
で自動的に推論される- サーバーサイドでデータ取得しているため、コンポーネントのレンダリング時にはデータが必ず存在する
この時点で、ページにアクセスすると最初の20件のアイテムが表示されます。
6. 無限スクロールの実装
ここからが本題です。useFetcher
を使って無限スクロールを実装します。
6-1. 記事一覧の取得 API (loader) の追加
// app/routes/api/articles.ts
import { type LoaderFunctionArgs } from "react-router";
import { fetchArticles, addNewArticles } from "~/utils/mockApi";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1");
const data = await fetchArticles({ page });
return {
articles: data.articles,
hasMore: data.hasMore,
currentPage: page,
totalCount: data.totalCount,
};
}
routes.ts
に作成したファイルをルーティングに設定します。/api/articles
エンドポイントでアクセスできるようになります。
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('/api/articles', 'routes/api/articles.ts'), // ← この行を追加
] satisfies RouteConfig;
ポイント:
app/routes/home.tsx
のloader
と、app/routes/api/articles.ts
のloader
を分ける理由- 返却している値は共通のため、同じ loader でも良いとも言えるかもしれません。
- しかし、
app/routes/home.tsx
のloader
は、画面遷移時に取得するデータのため、追加データの取得とは明確に役割が異なる点があります。 - また、
app/routes/home.tsx
のloader
では、画面遷移時に必要なデータを全て返すことが想定されます。現在は 記事一覧のデータだけ返していますが、将来的に コメント一覧や著者一覧のデータも必要になるかもしれません。その時に、共通のloader
を使用している場合、追加データの取得のたびに、コメントや著者のデータも一緒にリクエストされることになるため、パフォーマンス低下やバグにつながります。
6-2. useFetcher によるデータ取得
先ほど作成した loader
に useFetcher
を用いてリクエストを行う処理を実装します。
// app/routes/home.tsx に追加
import {
Box,
CircularProgress,
// ...省略
} from "@mui/material";
// ...省略
export async function loader() {
// 省略
}
export default function Home({ loaderData }: Route.ComponentProps) {
const fetcher = useFetcher<FetchArticlesResponse>();
// 状態管理を追加
const [articles, setArticles] = useState<Article[]>(loaderData.articles);
const [page, setPage] = useState(loaderData.currentPage);
const [hasMore, setHasMore] = useState(loaderData.hasMore);
const [isLoading, setIsLoading] = useState(false);
// 新しいページのデータ取得
const loadMore = useCallback(() => {
if (hasMore && fetcher.state === 'idle' && !isLoading) {
const nextPage = page + 1;
setPage(nextPage);
setIsLoading(true);
fetcher.load(`/api/articles?page=${nextPage}`);
}
}, [hasMore, page, fetcher, isLoading]);
// fetcher からデータが返ってきたら統合する
useEffect(() => {
const data = fetcher.data;
if (data?.articles) {
setArticles(prev => [...prev, ...data.articles]);
setHasMore(data.hasMore);
setIsLoading(false);
}
}, [fetcher.data]);
return (
<Container sx={{ p: 4 }}>
<List sx={{ mt: 2.5 }}>
{/* 記事一覧 (省略) */}
</List>
{hasMore && (
<Box sx={{ p: 2.5, textAlign: "center" }}>
{isLoading ? (
<CircularProgress size={24} />
) : (
<button onClick={loadMore}>Load more</button>
)}
</Box>
)}
</Container>
);
}
ポイント:
- コンポーネントで表示するデータは loaderData の値から、 useState で定義されたステートの値に変更されています。useState の初期値として、 loaderData の値を渡しています。
fetcher.load(url)
で指定したURLの loader を、画面遷移を伴わずにリクエストを送信します。- クエリパラメータでページ番号を渡す
- 返却されたデータは
useEffect
により検知し、状態を更新します。 fetcher.state
で通信状態(idle
,loading
)を監視し、重複リクエストやローディングに使用します。
6-3. Intersection Observer の設定
画面下部に到達したことを検知するために、Intersection Observer API を使用します。React で Intersection Observer API を利用するためのライブラリがあるため、今回はそちらを使用します。
npm i -D react-intersection-observer
https://github.com/thebuilder/react-intersection-observer
// app/routes/home.tsx に追加
// ...省略
import { useInView } from 'react-intersection-observer';
export async function loader() {
// 省略
}
export default function Home({ loaderData }: Route.ComponentProps) {
// ...省略
// 新しいページのデータ取得
const loadMore = useCallback(/* 省略 */);
// ...省略
// Intersection Observer のセットアップ
const { ref: observerRef, inView } = useInView({
threshold: 0,
rootMargin: "100px",
});
// Intersection Observer がトリガーされたら読み込み
useEffect(() => {
if (inView && hasMore && fetcher.state === 'idle' && !isLoading) {
loadMore();
}
}, [inView, hasMore, fetcher.state, loadMore, isLoading]);
return (
<Container sx={{ p: 4 }}>
<List sx={{ mt: 2.5 }}>
{/* 省略 */}
</List>
{hasMore && (
<Box ref={observerRef} sx={{ p: 2.5, textAlign: "center" }}>
{isLoading ? (
<CircularProgress size={24} />
) : (
<p>スクロールしてさらに読み込む</p>
)}
</Box>
)}
</Container>
);
}
ポイント:
observerRef
を ref で参照し、Intersection Observer で監視inView
で Intersection Observer の状態を監視し、useEffect
内でloadMore
関数を実行する
完成したコード
完成系のコードです。
// app/routes/home.tsx
import {
Box,
Typography,
CircularProgress,
List,
ListItem,
ListItemText,
Container,
} from "@mui/material";
import { fetchArticles } from "~/utils/mockApi";
import type { Route } from "./+types/home";
import { useFetcher } from "react-router";
import type { Article, FetchArticlesResponse } from "~/types";
import { useCallback, useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
export async function loader() {
// 初回ロード時は最初のページのデータのみ取得
const data = await fetchArticles({ page: 1, limit: 20 });
return {
articles: data.articles,
hasMore: data.hasMore,
currentPage: 1,
totalCount: data.totalCount,
};
}
export default function Home({ loaderData }: Route.ComponentProps) {
const fetcher = useFetcher<FetchArticlesResponse>();
// 状態管理を追加
const [articles, setArticles] = useState<Article[]>(loaderData.articles);
const [page, setPage] = useState(loaderData.currentPage);
const [hasMore, setHasMore] = useState(loaderData.hasMore);
const [isLoading, setIsLoading] = useState(false);
// 新しいページのデータ取得
const loadMore = useCallback(() => {
if (hasMore && fetcher.state === "idle" && !isLoading) {
const nextPage = page + 1;
setPage(nextPage);
setIsLoading(true);
fetcher.load(`/api/articles?page=${nextPage}`);
}
}, [hasMore, page, fetcher, isLoading]);
// fetcher からデータが返ってきたら統合する
useEffect(() => {
const data = fetcher.data;
if (data?.articles) {
setArticles((prev) => [...prev, ...data.articles]);
setHasMore(data.hasMore);
setTimeout(() => {
// データ取得が完了したらローディングを解除
setIsLoading(false);
}, 100);
}
}, [fetcher.data]);
// Intersection Observer のセットアップ
const { ref: observerRef, inView } = useInView({
threshold: 0,
rootMargin: "100px",
});
// Intersection Observer がトリガーされたら読み込み
useEffect(() => {
if (inView && hasMore && fetcher.state === "idle" && !isLoading) {
loadMore();
}
}, [inView, hasMore, fetcher.state, loadMore, isLoading]);
return (
<Container sx={{ p: 4 }}>
<Typography variant="h4" component="h1">
Articles ({articles.length})
</Typography>
<List sx={{ mt: 2.5 }}>
{articles.map((article) => (
<ListItem key={article.id}>
<ListItemText>
<Typography variant="subtitle1">{article.title}</Typography>
<Typography variant="body2">{article.description}</Typography>
</ListItemText>
</ListItem>
))}
</List>
{hasMore && (
<Box ref={observerRef} sx={{ p: 2.5, textAlign: "center" }}>
{isLoading ? <CircularProgress size={24} /> : <p>スクロールしてさらに読み込む</p>}
</Box>
)}
{!hasMore && (
<Box sx={{ p: 2.5, textAlign: "center", color: "text.secondary" }}>End of list</Box>
)}
</Container>
);
}
// app/routes/api/articles.ts
import { type LoaderFunctionArgs } from "react-router";
import { fetchArticles } from "~/utils/mockApi";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1");
const data = await fetchArticles({ page });
return {
articles: data.articles,
hasMore: data.hasMore,
currentPage: page,
totalCount: data.totalCount,
};
}
今回は簡単のため省略しましたが、実際にはエラーハンドリングも必要になります。また、useFetcher
によるデータ取得は記述量は増えがちなため、ロジックをカスタムフックに切り出すことを検討するのも良いかもしれません。
最後に
この記事では、React Router v7 の useFetcher
を使った無限スクロールの実装方法を解説しました。
今回紹介した無限スクロールのような機能は、ページ遷移を最小化し、ストレスなく閲覧できる体験を提供できるため、ユーザーの離脱率を下げ、エンゲージメント率の向上に直結します。
プロダクトの課題やビジネスゴールに応じてこのような技術を導入することで、パフォーマンス最適化、ユーザー体験の向上など価値を発揮することができます。
useFetcher の主な利点:
- URLを変更せずにデータ取得できる
- 状態管理が簡単(
fetcher.state
、fetcher.data
) - 型安全で開発体験が良い
実装のポイント:
loader
でデータを取得useFetcher
で追加データを取得- Intersection Observer で自動読み込みを実現
ぜひプロジェクトで活用してみてください!
Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています
弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?
もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!
求人応募してみる!Gaji-Labo フロントエンドエンジニア向けご案内資料
Gaji-Labo は新規事業やサービス開発に取り組む、事業会社・スタートアップへの支援を行っています。
弊社では、Next.js を用いた Web アプリケーションのフロントエンド開発をリードするフロントエンドエンジニアを募集しています!さまざまなプロダクトやチームに関わりながら、一緒に成長を体験しませんか?
もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください!