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も同様に拡張
})
);
ポイント
無限スクロールとページネーションの実装における重要なポイント:
-
ページ/オフセット追跡: 現在の位置を追跡して、次のデータセットをどこから取得するかを判断します。
-
ステート更新戦略: 新しいデータを既存のデータに追加するか(無限スクロール)、置き換えるか(ページネーション)を決定します。
-
スクロール位置管理: 特に「戻る」操作後にユーザーの位置を維持することが重要です。
-
ローディングインジケーター: ユーザーに追加データの読み込み中であることを伝えます。
-
エッジケース処理: データがない、利用可能なデータがすべて読み込まれた、エラーが発生した、などの状況を処理します。
無限スクロールとページネーションの比較
機能 | 無限スクロール | ページネーション |
---|---|---|
ユーザー体験 | シームレスなスクロール体験 | 明示的なページ移動 |
メモリ使用量 | 増加し続ける可能性あり | 一定量に抑えられる |
特定ページへのリンク | 難しい | 容易 |
SEO | 最適化が難しい | 優れている |
スクロール位置の保持 | 実装が複雑 | 比較的容易 |
パフォーマンス最適化のヒント
-
ウィンドウ化: 表示範囲外のアイテムを DOM から削除することで、パフォーマンスを向上させます。
-
データの正規化: 重複するデータを避け、メモリ使用量を削減します。
-
遅延読み込み: 画像などの重いリソースは、表示範囲に入ったときに読み込みます。
-
スクロールイベントの最適化: スクロールイベントのハンドラには
requestAnimationFrame
やthrottle
/debounce
を使用します。 -
状態の分割: すべてのアイテムが同じストアを参照するのではなく、必要に応じて状態を分割します。
まとめ
この記事では、Zustand を使った無限スクロールとページネーションの実装パターンについて解説しました。どちらの手法も大量のデータを効率的に表示するために重要ですが、アプリケーションの要件に応じて適切な方法を選択することが重要です。
無限スクロールは、ソーシャルメディアフィードやニュースフィードなど、連続的なコンテンツ消費に適しています。一方、ページネーションは検索結果や管理インターフェースなど、特定のページへの参照が重要な場合に適しています。
Zustand のシンプルな状態管理モデルは、こうした複雑な UI パターンの実装においても、コードの可読性と保守性を高めるのに役立ちます。
関連リンク
- Zustand 公式ドキュメント
- Intersection Observer API - スクロール検出の効率的な方法
- React Window - 大量のデータを効率的に表示するための仮想化ライブラリ
- React Virtualized - 高度な仮想化コンポーネント集
- Web.dev - パフォーマンス最適化 - Web パフォーマンス向上のためのテクニック
- article
Zustandでの非同期処理とfetch連携パターン(パターン 4: WebSocket とリアルタイム更新)
- article
Zustandでの非同期処理とfetch連携パターン(パターン 2: 楽観的更新(Optimistic Updates))
- article
Zustandでの非同期処理とfetch連携パターン(パターン1: 基本的なデータフェッチング)
- article
Zustandでの非同期処理とfetch連携パターン(基本的な導入と概要)
- article
Zustand × TypeScript:型安全にストアを構築する設計
- article
useStoreフック徹底解説:Zustandの基本操作をマスターしよう
- article
Zustandでの非同期処理とfetch連携パターン(パターン 4: WebSocket とリアルタイム更新)
- article
Zustandでの非同期処理とfetch連携パターン(パターン 2: 楽観的更新(Optimistic Updates))
- article
Zustandでの非同期処理とfetch連携パターン(パターン1: 基本的なデータフェッチング)
- article
Zustandでの非同期処理とfetch連携パターン(基本的な導入と概要)
- article
React × Suspenseを組み合わせてスケーラブルな非同期UIを実現する方法
- article
Next.js × Suspenseを活用した非同期ルーティング体験を実現する方法