この記事の対象読者
React でデータ取得を useEffect と useState で書いている人に向けた入門記事です。
- ローディングとエラーの state を毎回書いていて、面倒だと感じている
- 同じデータを複数のコンポーネントで使うとき、どう共有するか決めきれていない
- TanStack Query の名前は知っているが、何が解決されるのか分かっていない
React の基本的なフックが分かれば読めます。
実行結果は React 19.2.8 / TanStack Query 5.102.8 / Vite 8.2.2 / Node.js 22 で確認したものです。回数を数えるため StrictMode は外しています(開発時に effect が2回実行されるため)。
いまの書き方
ユーザー名を表示するだけのコンポーネントを考えます。useEffect で取ってくる、よくある形です。
function UserName() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(1)
.then((u) => { if (!cancelled) setUser(u); })
.catch((e) => { if (!cancelled) setError(e); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
if (loading) return <span>読み込み中</span>;
if (error) return <span>エラー</span>;
return <span>{user?.name}</span>;
}
state が3つと、後片付けの cancelled が要ります。取得するデータが増えるたびに、この形を書き写すことになります。
面倒ではありますが、動きます。問題は別のところにあります。
同じデータを2箇所で使うと2回叩く
このコンポーネントを、ヘッダーとサイドバーの2箇所に置きます。
<p>ヘッダー: <UserName /></p>
<p>サイドバー: <UserName /></p>
同じユーザーの情報なので、通信は1回で済むはずです。実際に fetchUser が呼ばれた回数を数えてみます。
リクエスト回数: 2
2回でした。当然で、コンポーネントがそれぞれ独立して useEffect を持っているからです。お互いのことを知りません。
避けるには、取得する場所を親に上げて props で配るか、Context に載せるか、状態管理のライブラリを入れるか、といった判断が要ります。データ取得の都合で、コンポーネントの構造を決めることになります。
useQuery に置き換える
同じものを TanStack Query で書きます。
npm install @tanstack/react-query
アプリ全体を Provider で囲みます。
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
コンポーネントはこうなります。
function UserName() {
const { data, isPending, isError } = useQuery({
queryKey: ['user', 1],
queryFn: () => fetchUser(1),
});
if (isPending) return <span>読み込み中</span>;
if (isError) return <span>エラー</span>;
return <span>{data.name}</span>;
}
state の宣言が消えました。cancelled の後片付けも要りません。
置き場所は変えずに、さっきと同じく2箇所に置いて数えます。
リクエスト回数: 1
1回になりました。 コンポーネントの構造は何も変えていません。
なぜ1回になるのか
鍵は queryKey です。
queryKey: ['user', 1]
TanStack Query は、この配列を問い合わせの識別子として扱います。同じキーの問い合わせは同じものとみなされます。
2つのコンポーネントが同時に ['user', 1] を要求すると、Query は「それは今まさに取得中のものと同じだ」と判断して、通信をまとめます。結果は両方に配られます。
データの共有先を親に上げる必要がなかったのは、共有の単位がコンポーネントの木ではなくキーになっているからです。どこに置いても、キーが同じなら同じデータを見ます。
キーに値を含めるのは、区別のためです。['user', 1] と ['user', 2] は別のデータなので、別々に取得されて別々に保持されます。
キャッシュと staleTime
取得したデータは保持されます。画面を離れて戻ってきたときに、何が起きるかを見ます。
一度表示してからアンマウントし、また表示する、という操作をして数えます。
リクエスト回数: 2
1つ増えました。キャッシュがあるのに通信しています。
これは既定の設定によるものです。TanStack Query の staleTime は既定が 0 で、取得した直後から「古いかもしれない」扱いになります。 再表示のきっかけがあると、裏で取り直しにいきます。
このとき画面には、キャッシュに残っていたデータがすぐ表示されます。読み込み中の表示を挟まずに中身が出て、その裏で新しいデータを取ってきて、届いたら差し替える動きです。
取り直しが要らないなら、staleTime を設定します。
useQuery({
queryKey: ['user', 1],
queryFn: () => fetchUser(1),
staleTime: 60_000, // 60秒は新鮮とみなす
});
同じ操作をもう一度します。
リクエスト回数: 1
増えませんでした。 60秒のあいだはキャッシュをそのまま使うので、通信が発生しません。
まとめるとこうなります。
| 操作 | staleTime 既定(0) | staleTime 60秒 |
|---|---|---|
| 初回・2コンポーネント | 1 | 1 |
| アンマウントして再表示 | +1 | +0 |
この設定がいちばん最初に迷うところだと思います。 更新が頻繁なデータなら既定のままでよく、めったに変わらないマスタデータなら長めに取る、という決め方になります。
キーが変わると取り直す
表示するユーザーを切り替える場合を見ます。キーに id を含めます。
function UserName({ id }: { id: number }) {
const { data, isPending } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
if (isPending) return <span>読み込み中</span>;
return <span>{data.name}</span>;
}
id を 1 → 2 → 1 と切り替えて数えます。
| 操作 | 累計 |
|---|---|
| id=1 を表示 | 1 |
| id=2 に切り替え | 2 |
| id=1 に戻る | 3 |
id を変えると、キーが変わるので新しい取得が走ります。依存する値をキーに入れておけば、変わったときに取り直されます。 useEffect の依存配列に相当する役割です。
id=1 に戻ったときも1つ増えています。これは前の節の staleTime が既定の 0 だからで、キャッシュは残っているものの古い扱いになり、裏で取り直しにいきます。表示自体はキャッシュから即座に出ます。
状態の扱いが揃う
副次的ですが、効いてくる点があります。
const { data, isPending, isError } = useQuery({ ... });
読み込み中とエラーの表し方が、どのデータ取得でも同じ形になります。useEffect で書いていたときは、命名も判定の順序も書く人と場所によってばらついていました。
新しく取得を追加するときに、判断することが減ります。
まとめ
-
同じ
queryKeyの問い合わせはまとめられます。 2コンポーネントから同時に要求しても通信は1回でした - 共有の単位がコンポーネントの木ではなくキーなので、データ取得の都合で構造を決めなくて済みます
-
staleTimeの既定は0です。取得直後から古い扱いになり、再表示のきっかけで取り直します - 依存する値をキーに入れておくと、変わったときに取り直されます。
useEffectの依存配列にあたる役割です -
staleTimeを設定すると、その間はキャッシュを使って通信しません。再表示しても回数は増えませんでした -
isPendingとisErrorで、状態の扱いがどの取得でも同じ形になります
自前で書いていたローディングとエラーの state が消えるのは分かりやすい利点ですが、実際に効くのは同じデータを複数箇所で使えるようになることのほうだと思います。取得の重複を避けるために構造を変える、という判断が要らなくなります。
書き込み側の useMutation はこの記事では扱っていません。まずは読み取りだけ置き換えてみるのが入りやすいと思います。