useEffect による手動データ取得の落とし穴と、TanStack Query が最善である理由
Olivia Novak
Dev Intern · Leapcell

はじめに
フロントエンド開発のダイナミックな世界では、非同期操作、特にデータ取得の管理は、インタラクティブで応答性の高いユーザーインターフェイスを構築するための礎です。React の useEffect フックは、副作用の処理に強力ですが、データ取得にもよく利用されてきました。しかし、この一般的なアプローチは、細心の注意を払って扱わないと、競合状態、過度な再レンダリング、複雑な同期ロジックなど、さまざまな問題にすぐに発展する可能性があります。この記事では、「useEffect データ取得アンチパターン」という多くの開発者が直面する一般的な問題について掘り下げ、TanStack Query(旧 React Query)のような最新のデータ取得ライブラリを採用することが、はるかにエレガントで効率的、かつ保守性の高いソリューションを提供する理由を論じます。これらの課題を理解し、より良いパターンを採用することは、単なる美的な選択ではなく、アプリケーションのパフォーマンス、開発者エクスペリエンス、および長期的な保守性に直接影響します。
useEffect による手動データ取得の問題点
TanStack Query を採用する理由について詳しく説明する前に、関連するコアコンセプトと問題点について共通の理解を確立しましょう。
主要な用語
- 副作用 (Side Effect): React において、副作用とはコンポーネントのスコープ外に影響を与えるあらゆる操作です。データ取得、DOM の手動変更、サブスクリプション、タイマーなどが例として挙げられます。
useEffectはこれらを処理するために設計されています。 - 競合状態 (Race Condition): 競合状態は、2 つ以上の操作(例: データ取得)が同時に実行され、その結果がそれらが完了する特定の順序に依存する場合に発生します。順序が管理されない場合、古い、または不正確な状態が表示される可能性があります。
- 古いデータ (Stale Data): データソースが更新されたが、クライアント側の表現が更新されていないため、もはや最新または正確ではないデータ。
- キャッシュ無効化 (Cache Invalidation): キャッシュされたデータを古いものとしてマークし、新鮮なデータの再取得を強制するプロセス。
- クエリキー (Query Key): TanStack Query において、キャッシュ内のサーバー状態の特定の断片を識別するために使用される一意の識別子(通常は配列)。
useEffect データ取得アンチパターン
典型的な useEffect データ取得アプローチとその固有の問題を例示しましょう。
投稿のリストを取得するシンプルなコンポーネントを考えます。
import React, { useState, useEffect } from 'react'; function PostList() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchPosts = async () => { try { setLoading(true); const response = await fetch('https://api.example.com/posts'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setPosts(data); } catch (e) { setError(e); } finally { setLoading(false); } }; fetchPosts(); }, []); // 空の依存配列は、マウント時に一度だけ実行されることを意味します if (loading) return <div>Loading posts...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <h1>Posts</h1> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); } export default PostList;
これは単純に見えますが、検索入力フィールドを追加する必要があると想像してください。
import React, { useState, useEffect } from 'react'; function SearchablePostList() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); useEffect(() => { // クリーンアップと競合状態のためにabort controllerを追加 const abortController = new AbortController(); const signal = abortController.signal; const fetchPosts = async () => { try { setLoading(true); setError(null); // 前のエラーをクリア const url = `https://api.example.com/posts?q=${searchTerm}`; const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setPosts(data); } catch (e) { if (e.name === 'AbortError') { console.log('Fetch aborted'); } else { setError(e); } } finally { setLoading(false); } }; // より良いUXのために検索入力のためのデバウンスを追加 const debounceTimeout = setTimeout(() => { fetchPosts(); }, 300); // クリーンアップ関数 return () => { clearTimeout(debounceTimeout); abortController.abort(); // アンマウント時または依存配列の変更時に進行中のfetchを中止 }; }, [searchTerm]); // searchTerm が変更されたときに再実行 if (loading) return <div>Loading posts...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <input type="text" placeholder="Search posts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> <h1>Posts</h1> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }
コードがどれほど急速に複雑になるかに注目してください。
- 状態管理のオーバーヘッド:
posts、loading、error状態を手動で管理します。データ取得操作ごとに、このボイラープレートが繰り返されます。 - 競合状態:
searchTermが急速に変更されると、複数の fetch リクエストが同時に進行する可能性があります。適切なクリーンアップ(AbortControllerのような)がないと、古い、または不正確なデータが表示される原因となる、遅い古いリクエストが新しい、より速いリクエストの後に解決される可能性があります。 - 再取得ロジック: ユーザーが別の場所に移動して戻ってきた場合はどうなりますか?または、サーバー上でデータが古くなった場合はどうなりますか?自動再取得やバックグラウンド更新の組み込みメカニズムがありません。
- キャッシュ: キャッシュメカニズムがありません。コンポーネントがマウントされるたびに、または依存配列が変更されるたびに、データが最初から再取得されます。これはパフォーマンスと API の使用に影響します。
- リクエストの重複除去: 複数のコンポーネントが同時に同じデータを取得しようとすると、すべてが個別のリクエストを行うことになります。
- エラー処理と再試行: 基本的なエラー処理は存在しますが、失敗時の自動再試行のような高度な機能はありません。
- コンポーネント間の同期: 同じリソースを取得しようとする可能性のある異なるコンポーネント間でデータを共有することは、コンテキストまたはグローバル状態管理を必要とし、複雑になります。
この「アンチパターン」は useEffect が本質的に悪いということではなく、useEffect がサーバー状態の複雑なライフサイクル管理と最適化を必要とする問題の解決に適したツールではないということです。useEffect は fetch を 開始 できますが、サーバー状態の完全なライフサイクルを管理する固有の機能が欠けています。
TanStack Query の登場
TanStack Query(しばしば React Query とも呼ばれます)は、React アプリケーションでサーバー状態を管理、キャッシュ、同期するための強力なライブラリです。これは、fetch を副作用として扱うことから、独自のライフサイクルと懸念事項を持つサーバー州としてデータを扱うパラダイムに移行します。これはローカル UI 状態とは異なります。
TanStack Query を使用した場合の SearchablePostList コンポーネントの例を次に示します。
import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; // 説明のためのシンプルな人工的なデバウンスフック // 通常のアプリでは、'use-debounce'のようなライブラリやReactのuseDeferredValueを使用するかもしれません function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; } function SearchablePostListWithQuery() { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 500); // 検索入力をデバウンス const fetchPosts = async (queryKey) => { const [_key, { q }] = queryKey; // クエリキーをデストラクチャ const url = `https://api.example.com/posts?q=${q}`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }; const { data: posts, isLoading, isError, error, isFetching // データが現在取得中であることを示します(バックグラウンド再取得に便利) } = useQuery({ queryKey: ['posts', { q: debouncedSearchTerm }], // このクエリの一意のキー queryFn: fetchPosts, staleTime: 5 * 60 * 1000, // データは5分間は「新鮮」と見なされます keepPreviousData: true, // 新しいデータが取得されている間、前回のデータを表示し続けます }); if (isError) return <div>Error: {error.message}</div>; return ( <div> <input type="text" placeholder="Search posts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> {isLoading && debouncedSearchTerm === '' ? ( <div>Loading posts...</div> ) : isFetching ? ( <div>Searching for "{debouncedSearchTerm}"...</div> ) : null} <h1>Posts</h1> <ul> {posts?.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); } export default SearchablePostListWithQuery;
ここでの利点を分解してみましょう。
- 宣言的なデータ取得:
queryKeyとqueryFnを指定してuseQueryを宣言するだけです。TanStack Query が「どのように」するかを処理します。 - 自動状態管理:
isLoading、isError、dataはすべてuseQueryによって提供されます。これらの手動useStateはもう不要です。 - キャッシュと重複除去: TanStack Query は
queryKeyに基づいてデータを自動的にキャッシュします。別のコンポーネントが、キャッシュ内(かつ古くない)にある['posts', { q: 'react' }]を取得しようとすると、新しいネットワークリクエストなしで即座にキャッシュされたデータが取得されます。同じキーに対する保留中のリクエストも重複除去されます。 - Stale-While-Revalidate: デフォルトでは、TanStack Query は「stale-while-revalidate」キャッシング戦略を使用します。これは、バックグラウンドで透過的に最新のデータを取得しながら、古いデータを即座に提供します。これにより、優れたユーザーエクスペリエンスが提供されます。
staleTimeオプションにより、データがバックグラウンドでの再取得の対象となるまで、データが「新鮮」と見なされる期間を構成できます。 - 競合状態の防止: TanStack Query は、最新のクエリ関数呼び出しの結果のみが状態に適用されるように内部的に競合状態を処理し、順序外で解決する可能性のある以前のプロミスを効果的に中止します。
- バックグラウンド再取得: データは自動的に再取得されます。
- ユーザーがインターネットに再接続したとき。
- ウィンドウがフォーカスされたとき。
- クエリキーが変更されたとき。
- 手動で再取得をトリガーしたとき。
- エラー処理と再試行: クエリの失敗時に指数バックオフを伴う組み込み再試行メカニズム。
keepPreviousData: この強力なオプションは、queryKeyが変更されたとき(例:searchTermが更新されたとき)、新しいデータがロードされている間、UI が突然空になるのを防ぐことを保証します。新しいデータが到着するまで、古いデータをスムーズに表示します。- Devtools: TanStack Query には、キャッシュ、クエリ、ミューテーションを検査するための優れた devtools が付属しています。
いつ何を使うか
TanStack Query はサーバー状態に最適ですが、useEffect は他の副作用にその場があります。
- UI 副作用のための
useEffect: DOM の操作、イベントリスナーの設定、外部ストアへのサブスクライブ(useContextが十分でないグローバルテーマコンテキストのような)、または DOM に直接影響するサードパーティライブラリの統合。 - ローカル状態同期のための
useEffect: ローカルコンポーネントの状態をプロップや他のローカル状態と同期する(例: アイテム ID プロップが変更されたときにフォームフィールドをリセットする)。
useEffect は汎用的な副作用を表現するための低レベルプリミティブです。TanStack Query は、非同期サーバー状態の複雑さを処理するために特別に設計された高レベル抽象化です。
結論
useEffect データ取得アンチパターンは、サーバー状態管理の特殊な問題に対処するために汎用的な副作用フックを使用したことから生じます。簡単なケースでは機能しますが、キャッシュ、同期、競合状態を扱う際には、かなりのボイラープレートと複雑さが導入されます。TanStack Query は、サーバーデータを宣言的に管理するための堅牢で、意見があり、高度に最適化されたソリューションを提供することで、これらの懸念から開発者を解放します。TanStack Query のようなライブラリを採用することで、アプリケーションのパフォーマンス、回復力、保守性を向上させ、開発者とユーザーのエクスペリエンスを向上させることができます。コンポーネントに UI に集中させ、専用ツールにデータ管理を任せる時が来ました。