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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?