T-CREATOR

Zustandでの非同期処理とfetch連携パターン(パターン 3: 無限スクロールとページネーション)

Zustandでの非同期処理とfetch連携パターン(パターン 3: 無限スクロールとページネーション)

大量のデータを効率的に表示する手法として、無限スクロールとページネーションは現代の Web アプリケーションに欠かせない機能です。この記事では、Zustand を使った無限スクロールとページネーションの実装方法について詳しく解説します。

パターン 3: 無限スクロールとページネーション

大量のデータを扱う場合、無限スクロールやページネーションが一般的です。Zustand でこれらのパターンを実装する方法を見ていきましょう。

ユースケース: 記事リストの無限スクロール

記事のリストを無限スクロールで表示する例を考えてみます:

typescript// src/stores/articleStore.ts
import { create } from 'zustand';

interface Article {
  id: string;
  title: string;
  content: string;
  author: string;
  createdAt: string;
}

interface ArticleStore {
  articles: Article[];
  hasMore: boolean;
  page: number;
  isLoading: boolean;
  error: string | null;

  fetchArticles: () => Promise<void>;
  loadMoreArticles: () => Promise<void>;
}

export const useArticleStore = create<ArticleStore>(
  (set, get) => ({
    articles: [],
    hasMore: true,
    page: 1,
    isLoading: false,
    error: null,

    fetchArticles: async () => {
      // 初期データ取得(リセット)
      set({
        isLoading: true,
        error: null,
        page: 1,
        articles: [],
      });

      try {
        const response = await fetch(
          '/api/articles?page=1&limit=10'
        );
        if (!response.ok)
          throw new Error('Failed to fetch articles');

        const data = await response.json();
        set({
          articles: data.articles,
          hasMore: data.hasMore,
          isLoading: false,
          page: 1,
        });
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : '未知のエラー',
          isLoading: false,
        });
      }
    },

    loadMoreArticles: async () => {
      const { hasMore, isLoading, page } = get();

      // すでに読み込み中か、もう記事がない場合は何もしない
      if (isLoading || !hasMore) return;

      const nextPage = page + 1;

      set({ isLoading: true, error: null });

      try {
        const response = await fetch(
          `/api/articles?page=${nextPage}&limit=10`
        );
        if (!response.ok)
          throw new Error('Failed to fetch more articles');

        const data = await response.json();

        // 既存の記事に新しい記事を追加
        set((state) => ({
          articles: [...state.articles, ...data.articles],
          hasMore: data.hasMore,
          page: nextPage,
          isLoading: false,
        }));
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : '未知のエラー',
          isLoading: false,
        });
      }
    },
  })
);

インターセクションオブザーバーとの連携

無限スクロールを実装するためには、スクロール位置を監視する必要があります。IntersectionObserver API を使った例を見てみましょう:

tsx// src/components/ArticleList.tsx
import React, {
  useEffect,
  useRef,
  useCallback,
} from 'react';
import { useArticleStore } from '../stores/articleStore';

export const ArticleList: React.FC = () => {
  const {
    articles,
    hasMore,
    isLoading,
    error,
    fetchArticles,
    loadMoreArticles,
  } = useArticleStore();
  const loaderRef = useRef<HTMLDivElement>(null);

  // 初回データ取得
  useEffect(() => {
    fetchArticles();
  }, []);

  // IntersectionObserverの設定
  const observerCallback = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [entry] = entries;
      if (entry.isIntersecting && hasMore && !isLoading) {
        loadMoreArticles();
      }
    },
    [hasMore, isLoading, loadMoreArticles]
  );

  // ローダー要素の監視を設定
  useEffect(() => {
    const observer = new IntersectionObserver(
      observerCallback,
      {
        root: null,
        threshold: 1.0,
      }
    );

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

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

  return (
    <div className='article-list'>
      {articles.map((article) => (
        <article key={article.id} className='article-card'>
          <h2>{article.title}</h2>
          <p className='article-meta'>
            著者: {article.author} | 投稿日:{' '}
            {new Date(
              article.createdAt
            ).toLocaleDateString()}
          </p>
          <p className='article-preview'>
            {article.content.substring(0, 100)}...
          </p>
        </article>
      ))}

      {error && (
        <div className='error-message'>{error}</div>
      )}

      <div ref={loaderRef} className='loader'>
        {isLoading && (
          <div className='spinner'>読み込み中...</div>
        )}
        {!hasMore && <p>すべての記事を読み込みました</p>}
      </div>
    </div>
  );
};

ページネーションの実装

無限スクロールの代わりにページネーションを使いたい場合は、以下のように実装できます:

tsx// src/components/PaginatedArticleList.tsx
import React, { useEffect } from 'react';
import { useArticleStore } from '../stores/articleStore';

export const PaginatedArticleList: React.FC = () => {
  const {
    articles,
    hasMore,
    isLoading,
    error,
    page,
    fetchArticles,
  } = useArticleStore();

  // 初回データ取得
  useEffect(() => {
    fetchArticles();
  }, []);

  // 特定のページに移動する関数
  const goToPage = async (pageNum: number) => {
    // ストアを拡張して、特定のページを読み込むメソッドを追加
    set({
      isLoading: true,
      error: null,
    });

    try {
      const response = await fetch(
        `/api/articles?page=${pageNum}&limit=10`
      );
      if (!response.ok)
        throw new Error(`Failed to fetch page ${pageNum}`);

      const data = await response.json();
      set({
        articles: data.articles,
        hasMore: data.hasMore,
        page: pageNum,
        isLoading: false,
      });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : '未知のエラー',
        isLoading: false,
      });
    }
  };

  return (
    <div className='article-list'>
      {/* 記事リスト */}
      {articles.map((article) => (
        <article key={article.id} className='article-card'>
          <h2>{article.title}</h2>
          <p className='article-meta'>
            著者: {article.author} | 投稿日:{' '}
            {new Date(
              article.createdAt
            ).toLocaleDateString()}
          </p>
          <p className='article-preview'>
            {article.content.substring(0, 100)}...
          </p>
        </article>
      ))}

      {error && (
        <div className='error-message'>{error}</div>
      )}

      {/* ページネーションコントロール */}
      <div className='pagination-controls'>
        <button
          onClick={() => goToPage(page - 1)}
          disabled={page === 1 || isLoading}
        >
          前のページ
        </button>

        <span className='page-info'>ページ {page}</span>

        <button
          onClick={() => goToPage(page + 1)}
          disabled={!hasMore || isLoading}
        >
          次のページ
        </button>
      </div>
    </div>
  );
};

無限スクロールの拡張: 仮想化リスト

大量のデータを扱う場合、DOM のパフォーマンスを向上させるために仮想化リストを使用することができます。以下はreact-windowライブラリを使った例です:

tsx// src/components/VirtualizedArticleList.tsx
import React, { useEffect } from 'react';
import { FixedSizeList } from 'react-window';
import { useArticleStore } from '../stores/articleStore';

const VirtualizedArticleList: React.FC = () => {
  const { articles, isLoading, error, fetchArticles } =
    useArticleStore();

  useEffect(() => {
    fetchArticles();
  }, []);

  // 各アイテムをレンダリングする関数
  const ArticleItem = ({ index, style }) => {
    const article = articles[index];

    if (!article) return null;

    return (
      <div style={style} className='article-item'>
        <h3>{article.title}</h3>
        <p>著者: {article.author}</p>
        <p>{article.content.substring(0, 100)}...</p>
      </div>
    );
  };

  if (isLoading && articles.length === 0) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return <div className='error-message'>{error}</div>;
  }

  return (
    <div className='virtualized-list-container'>
      <FixedSizeList
        height={500}
        width='100%'
        itemCount={articles.length}
        itemSize={150}
      >
        {ArticleItem}
      </FixedSizeList>
    </div>
  );
};

export default VirtualizedArticleList;

フィルタリングと検索の統合

ページネーションや無限スクロールにフィルタリングや検索機能を統合する例:

typescript// 拡張されたストア
const useArticleStore = create<ArticleStore>(
  (set, get) => ({
    articles: [],
    hasMore: true,
    page: 1,
    isLoading: false,
    error: null,

    // 検索とフィルタリングの状態
    searchQuery: '',
    filter: {
      category: null,
      dateRange: null,
      author: null,
    },

    // 検索クエリを設定
    setSearchQuery: (query: string) => {
      set({ searchQuery: query });
      // 検索条件が変わったので、最初からデータを再取得
      get().fetchArticles();
    },

    // フィルターを設定
    setFilter: (filter: Partial<Filter>) => {
      set((state) => ({
        filter: { ...state.filter, ...filter },
      }));
      // フィルター条件が変わったので、最初からデータを再取得
      get().fetchArticles();
    },

    // API呼び出しのパラメータを構築
    buildQueryParams: () => {
      const { searchQuery, filter, page } = get();
      const params = new URLSearchParams();

      params.append('page', String(page));
      params.append('limit', '10');

      if (searchQuery) {
        params.append('q', searchQuery);
      }

      if (filter.category) {
        params.append('category', filter.category);
      }

      if (filter.author) {
        params.append('author', filter.author);
      }

      if (filter.dateRange) {
        params.append('from', filter.dateRange.from);
        params.append('to', filter.dateRange.to);
      }

      return params.toString();
    },

    fetchArticles: async () => {
      // 現在の検索条件とフィルターでクエリパラメータを構築
      const queryParams = get().buildQueryParams();

      set({
        isLoading: true,
        error: null,
        page: 1,
        articles: [],
      });

      try {
        const response = await fetch(
          `/api/articles?${queryParams}`
        );
        // 以下同様...
      } catch (error) {
        // エラー処理...
      }
    },

    // loadMoreArticlesも同様に拡張
  })
);

ポイント

無限スクロールとページネーションの実装における重要なポイント:

  1. ページ/オフセット追跡: 現在の位置を追跡して、次のデータセットをどこから取得するかを判断します。

  2. ステート更新戦略: 新しいデータを既存のデータに追加するか(無限スクロール)、置き換えるか(ページネーション)を決定します。

  3. スクロール位置管理: 特に「戻る」操作後にユーザーの位置を維持することが重要です。

  4. ローディングインジケーター: ユーザーに追加データの読み込み中であることを伝えます。

  5. エッジケース処理: データがない、利用可能なデータがすべて読み込まれた、エラーが発生した、などの状況を処理します。

無限スクロールとページネーションの比較

機能無限スクロールページネーション
ユーザー体験シームレスなスクロール体験明示的なページ移動
メモリ使用量増加し続ける可能性あり一定量に抑えられる
特定ページへのリンク難しい容易
SEO最適化が難しい優れている
スクロール位置の保持実装が複雑比較的容易

パフォーマンス最適化のヒント

  1. ウィンドウ化: 表示範囲外のアイテムを DOM から削除することで、パフォーマンスを向上させます。

  2. データの正規化: 重複するデータを避け、メモリ使用量を削減します。

  3. 遅延読み込み: 画像などの重いリソースは、表示範囲に入ったときに読み込みます。

  4. スクロールイベントの最適化: スクロールイベントのハンドラにはrequestAnimationFramethrottle/debounceを使用します。

  5. 状態の分割: すべてのアイテムが同じストアを参照するのではなく、必要に応じて状態を分割します。

まとめ

この記事では、Zustand を使った無限スクロールとページネーションの実装パターンについて解説しました。どちらの手法も大量のデータを効率的に表示するために重要ですが、アプリケーションの要件に応じて適切な方法を選択することが重要です。

無限スクロールは、ソーシャルメディアフィードやニュースフィードなど、連続的なコンテンツ消費に適しています。一方、ページネーションは検索結果や管理インターフェースなど、特定のページへの参照が重要な場合に適しています。

Zustand のシンプルな状態管理モデルは、こうした複雑な UI パターンの実装においても、コードの可読性と保守性を高めるのに役立ちます。

関連リンク