- JavaScript
- React
- TypeScript
- 技術 💻
Reactの状態管理の比較と使い分け|Zustand・TanStack Query
Reactの状態管理で最初に決めるべきなのは、どのライブラリを使うかではなく、「その状態はどこから来るのか」です。APIから取ってきたデータ(サーバーの状態)と、モーダルの開閉やフォームの入力(画面の状態)では、向いている管理方法がまったく違います。
状態管理ライブラリの比較記事は多くありますが、「結局どれを選べばいいか」がわからないまま終わるものも少なくありません。この記事では、状態を種類で分ける考え方と、質問に答えて選べる判断表、そして各ライブラリのコード例(TypeScriptの型チェックが通ることを確認済み)で、使い分けを解説します。
状態の種類と、向いている管理方法
| 状態の種類 | 例 | 向いている管理方法 |
|---|---|---|
| サーバーの状態 | 注文の一覧、ユーザーの情報 | TanStack Query(またはSWR、フレームワークのデータ取得の仕組み) |
| その部品だけの状態 | モーダルの開閉、入力中の文字 | useState / useReducer |
| あまり変わらない、広く使う値 | テーマ、ログイン中のユーザー、言語 | Context |
| 画面をまたいで共有し、よく変わる状態 | カートの中身、複数画面にまたがる入力 | Zustand / Jotai / Redux Toolkit |
| URLで共有したい状態 | 検索条件、ページ番号、タブ | URLのクエリパラメーター |
| フォームの状態 | 入力値、エラー、送信中 | React Hook Formなどのフォームライブラリ |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Reactの状態管理の比較と使い分け|Zustand・TanStack Query」
特に大きいのが「サーバーの状態」を分けることです。以前は、APIから取ったデータもReduxなどのグローバルな状態に入れて管理するのが一般的でした。しかし、サーバーのデータには「読み込み中」「エラー」「古くなったら取り直す」「更新したら一覧を取り直す」といった専用の悩みがあり、専用のライブラリに任せると、グローバルな状態に残るものは驚くほど少なくなります。
選び方の判断表
| 質問 | はい → | いいえ → |
|---|---|---|
| APIから取ってきたデータか | TanStack Query | 次へ |
| その部品の中だけで使うか | useState(状態の変化が複雑なら useReducer) | 次へ |
| URLを共有したときに再現したいか(検索条件など) | URLのクエリパラメーター | 次へ |
| テーマやログイン情報など、めったに変わらない値か | Context | 次へ |
| 画面をまたいで共有し、よく変わるか | Zustand(小さな状態をたくさん組み合わせるなら Jotai、大規模で厳密なルールが必要なら Redux Toolkit) | ― |
各方法の書き方
useState:その部品だけの状態
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
まずはuseStateで十分なことがほとんどです。親子の部品で共有したいときは、共通の親に状態を移して、propsで渡します(状態の持ち上げ)。
Context:あまり変わらない値を、深い階層に配る
import { createContext, useContext, type ReactNode } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<Theme>("light");
export function ThemeProvider({ children }: { children: ReactNode }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
export function ThemedTitle() {
const theme = useContext(ThemeContext);
return <h1 className={theme}>タイトル</h1>;
}
Contextは、propsを何階層もバケツリレーせずに値を届ける仕組みです。状態管理のライブラリではありません。
注意点:Contextの値が変わると、その値を使っているすべての部品が再レンダリングされます。カートのように頻繁に変わる値をContextに入れると、関係のない部品まで再描画されて重くなることがあります。頻繁に変わる値は、次のZustandなどに任せます。
Zustand:画面をまたいで共有する状態
import { create } from "zustand";
type CartItem = { id: number; name: string; qty: number };
type CartState = {
items: CartItem[];
add: (item: Omit<CartItem, "qty">) => void;
clear: () => void;
};
export const useCart = create<CartState>()((set) => ({
items: [],
add: (item) =>
set((s) => {
const found = s.items.find((i) => i.id === item.id);
return found
? { items: s.items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)) }
: { items: [...s.items, { ...item, qty: 1 }] };
}),
clear: () => set({ items: [] }),
}));
export function CartBadge() {
const count = useCart((s) => s.items.reduce((sum, i) => sum + i.qty, 0));
return <span>{count}</span>;
}
- Providerで囲む必要がなく、フック1つでどこからでも使える
useCart((s) => ...)のように必要な値だけを選ぶ(セレクター)と、その値が変わったときだけ再レンダリングされる- 書く量が少なく、学ぶことも少ない
Jotai:小さな状態(atom)を組み合わせる
import { atom, useAtom } from "jotai";
const keywordAtom = atom("");
const upperAtom = atom((get) => get(keywordAtom).toUpperCase()); // 派生した状態
export function Search() {
const [keyword, setKeyword] = useAtom(keywordAtom);
const [upper] = useAtom(upperAtom);
return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder={upper} />;
}
useState をそのままグローバルにしたような感覚で使えます。小さな状態を組み合わせて、そこから計算した値(派生した状態)を作るのが得意です。
TanStack Query:サーバーから取ってくるデータ
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
type Order = { id: number; item: string; status: "pending" | "paid" };
async function fetchOrders(): Promise<Order[]> {
const res = await fetch("/api/orders");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function payOrder(id: number): Promise<Order> {
const res = await fetch(`/api/orders/${id}/pay`, { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function OrderList() {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: ["orders"],
queryFn: fetchOrders,
staleTime: 30_000, // 30秒は新しいデータとして扱い、取り直さない
});
const pay = useMutation({
mutationFn: payOrder,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders"] }), // 一覧を取り直す
});
if (isPending) return <p>読み込み中…</p>;
if (error) return <p>エラー:{error.message}</p>;
return (
<ul>
{data.map((o) => (
<li key={o.id}>
{o.item}({o.status})
<button onClick={() => pay.mutate(o.id)} disabled={pay.isPending}>支払う</button>
</li>
))}
</ul>
);
}
アプリのルートを QueryClientProvider で囲んで使います。useEffectとuseStateで自分で書くと大変な、次のような処理をまとめて引き受けてくれます。
| 自分で書くと大変なこと | TanStack Queryでは |
|---|---|
| 読み込み中・エラーの状態の管理 | isPending・error が用意されている |
| 同じデータを複数の部品で取得したときの重複リクエスト | 同じ queryKey なら1回にまとめる |
| 画面に戻ったときの再取得、古いデータの取り直し | staleTime などで自動 |
| 更新したあとの一覧の取り直し | invalidateQueries で指定したデータを取り直す |
| 前回の結果のキャッシュ | 自動でキャッシュし、すぐに表示してから裏で更新 |
useEffectでfetchしてuseStateに入れる書き方から、TanStack Queryに置き換えるだけで、コードが短くなり、不具合も減ることが多いです。
Redux Toolkit:大規模で、厳密なルールが必要な場合
Redux Toolkitは、状態の変更を「アクション」として記録し、決まった手順でしか変更できないようにする仕組みです。書く量は多めですが、開発者ツールで状態の変化を追いやすく、大人数・大規模なアプリで変更のルールを揃えたい場合に向いています。サーバーのデータは、Redux Toolkitに含まれる RTK Query で扱えます。
ライブラリの比較
| Context | Zustand | Jotai | Redux Toolkit | TanStack Query | |
|---|---|---|---|---|---|
| 主な用途 | 値を配る | 共有する画面の状態 | 小さな状態の組み合わせ | 大規模な画面の状態 | サーバーの状態 |
| 追加のインストール | 不要(React標準) | 必要 | 必要 | 必要 | 必要 |
| Providerで囲む | 必要 | 不要 | 任意 | 必要 | 必要 |
| 書く量 | 少ない | 少ない | 少ない | 多め | 少ない |
| 再レンダリングの細かさ | 粗い(値を使う部品すべて) | セレクターで細かくできる | atom単位 | セレクターで細かくできる | queryKey単位 |
URLに持たせる状態
検索条件、並べ替え、ページ番号、選んでいるタブなどは、URLのクエリパラメーターに持たせるのがおすすめです。
- URLを共有すると、相手にも同じ画面が表示される
- ブラウザの「戻る」で、前の条件に戻れる
- 再読み込みしても状態が消えない
これらを状態管理のライブラリに入れると、URLの共有や再読み込みで状態が失われます。
よくある失敗
| 失敗 | 改善 |
|---|---|
| APIのデータをグローバルな状態にコピーして管理する | TanStack Queryなどに任せ、コピーしない |
| なんでもグローバルな状態に入れる | まずuseStateで書き、共有が必要になったら移す |
| 頻繁に変わる値をContextに入れて重くなる | Zustandなどに移す、Contextを分割する |
| 計算で求められる値も状態として持つ | 合計や件数は、元の状態から計算する(派生させる) |
| 検索条件を画面の状態で持ち、共有や再読み込みで消える | URLのクエリパラメーターに持たせる |
よくある質問
Q. Reduxはもう使われていないのですか? A. 現在も、大規模なアプリや、厳密な変更のルールが必要なプロジェクトで使われています。ただし、新しく始めるなら、従来のReduxではなく Redux Toolkit を使います。小〜中規模のアプリでは、より軽いZustandやJotaiが選ばれることが増えています。
Q. Next.jsのServer Componentsを使う場合はどうなりますか? A. サーバー側でデータを取得して表示する部分が増えるため、クライアント側で管理する状態はさらに少なくなります。画面の操作に関わる状態(モーダル、入力中の値など)は、引き続きuseStateやZustandなどで管理します。
Q. 既存のアプリのどこから見直せばいいですか? A. まず、APIのデータがグローバルな状態に入っていないかを確認するのがおすすめです。ここをTanStack Queryなどに移すだけで、グローバルな状態の量と、読み込み・エラー処理のコードが大きく減ることが多いです。
まとめ
- 最初に、状態を「サーバーの状態」と「画面の状態」に分ける
- サーバーのデータは TanStack Query に任せる。読み込み中・キャッシュ・取り直しを引き受けてくれる
- 画面の状態は、まず useState。共有が必要になったら移す
- Contextは値を配る仕組み。頻繁に変わる値には向かない
- 画面をまたぐ状態は Zustand(組み合わせるならJotai、大規模ならRedux Toolkit)
- 検索条件などは URL に持たせる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Reactの状態管理の比較と使い分け|Zustand・TanStack Query」
株式会社RJCでは、ReactやNext.jsを使ったフロントエンドの開発も、研修やプロジェクトを通じて仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。