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,
  };
}

ポイント:

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.tsxloader と、 app/routes/api/articles.tsloader を分ける理由
    • 返却している値は共通のため、同じ loader でも良いとも言えるかもしれません。
    • しかし、app/routes/home.tsxloader は、画面遷移時に取得するデータのため、追加データの取得とは明確に役割が異なる点があります。
    • また、app/routes/home.tsxloader では、画面遷移時に必要なデータを全て返すことが想定されます。現在は 記事一覧のデータだけ返していますが、将来的に コメント一覧や著者一覧のデータも必要になるかもしれません。その時に、共通の loader を使用している場合、追加データの取得のたびに、コメントや著者のデータも一緒にリクエストされることになるため、パフォーマンス低下やバグにつながります。

6-2. useFetcher によるデータ取得

先ほど作成した loaderuseFetcher を用いてリクエストを行う処理を実装します。

// 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.statefetcher.data
  • 型安全で開発体験が良い

実装のポイント:

  1. loader でデータを取得
  2. useFetcher で追加データを取得
  3. Intersection Observer で自動読み込みを実現

ぜひプロジェクトで活用してみてください!

Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています

弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

Gaji-Labo フロントエンドエンジニア向けご案内資料

Gaji-Labo は新規事業やサービス開発に取り組む、事業会社・スタートアップへの支援を行っています。

弊社では、Next.js を用いた Web アプリケーションのフロントエンド開発をリードするフロントエンドエンジニアを募集しています!さまざまなプロダクトやチームに関わりながら、一緒に成長を体験しませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください!

求人応募してみる!


投稿者 Takumi Shimojo

フロントエンドエンジニア。 通信会社でデザインシステムの構築や React / TypeScript の開発経験を経て、Gaji-Labo に参加。 Next.js / TailwindCSS / Chrome Extension / Figma が好きです。 デザイナーとエンジニアの両者の目線でプロダクトを作れる人材を目指しています。