【2026年版】React状態管理比較|Zustand・Jotai・TanStack Queryの使い分け

kento_morota 23分で読めます

Reactの状態管理は、かつてRedux一択の時代から大きく変化しました。2026年現在、軽量で直感的なZustandやJotai、サーバー状態に特化したTanStack Queryなど、目的に応じた選択肢が充実しています。

本記事では、これらの主要ライブラリの特徴と使い方を比較し、プロジェクトの要件に応じた最適な選び方を解説します。

React状態管理の現在地

状態管理を考える上で、まず「状態の種類」を整理することが重要です。

状態の分類

UIステート(クライアント状態):モーダルの開閉、フォーム入力値、テーマ設定など、クライアント側で完結する状態です。

サーバーステート:APIから取得したデータ、キャッシュ、ローディング状態、エラー状態など、サーバーとの同期が必要な状態です。

URLステート:ページネーション、フィルター、ソート条件など、URLパラメータに反映されるべき状態です。

かつてのReduxはこれらすべてを1つのストアで管理しようとしましたが、現在は状態の種類に応じて適切なツールを使い分けるのが主流です。

2026年の主要な選択肢

Zustand:シンプルなグローバル状態管理。小〜中規模のクライアント状態に最適。

Jotai:アトミックな状態管理。きめ細かいレンダリング最適化が特徴。

TanStack Query(React Query):サーバー状態の管理に特化。キャッシュ、再取得、楽観的更新を自動化。

React標準(useState/useContext):小規模な状態管理。外部ライブラリ不要。

Zustand:シンプルで直感的なストア

Zustandは、Reduxの複雑さを解消したシンプルなグローバル状態管理ライブラリです。ボイラープレートが少なく、学習コストが低いのが特徴です。

基本的な使い方

import { create } from "zustand";

// ストアの型定義
interface AuthStore {
  user: User | null;
  isAuthenticated: boolean;
  login: (user: User) => void;
  logout: () => void;
  updateProfile: (updates: Partial<User>) => void;
}

// ストアの作成
const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  isAuthenticated: false,

  login: (user) =>
    set({ user, isAuthenticated: true }),

  logout: () =>
    set({ user: null, isAuthenticated: false }),

  updateProfile: (updates) =>
    set((state) => ({
      user: state.user ? { ...state.user, ...updates } : null,
    })),
}));

// コンポーネントでの使用
function UserMenu() {
  // 必要な値だけセレクタで取得(不要な再レンダリングを防止)
  const user = useAuthStore((state) => state.user);
  const logout = useAuthStore((state) => state.logout);

  if (!user) return <button>ログイン</button>;

  return (
    <div>
      <span>{user.name}</span>
      <button onClick={logout}>ログアウト</button>
    </div>
  );
}

ミドルウェアの活用

import { create } from "zustand";
import { devtools, persist, immer } from "zustand/middleware";

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, "quantity">) => void;
  removeItem: (id: number) => void;
  updateQuantity: (id: number, quantity: number) => void;
  clearCart: () => void;
  totalPrice: () => number;
}

const useCartStore = create<CartStore>()(
  devtools(
    persist(
      immer((set, get) => ({
        items: [],

        addItem: (item) =>
          set((state) => {
            const existing = state.items.find((i) => i.id === item.id);
            if (existing) {
              existing.quantity += 1; // immerにより直接変更可能
            } else {
              state.items.push({ ...item, quantity: 1 });
            }
          }),

        removeItem: (id) =>
          set((state) => {
            state.items = state.items.filter((i) => i.id !== id);
          }),

        updateQuantity: (id, quantity) =>
          set((state) => {
            const item = state.items.find((i) => i.id === id);
            if (item) item.quantity = Math.max(0, quantity);
          }),

        clearCart: () => set({ items: [] }),

        totalPrice: () =>
          get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      })),
      { name: "cart-storage" } // localStorageに永続化
    ),
    { name: "CartStore" } // DevToolsでの表示名
  )
);

非同期処理の扱い

interface ProductStore {
  products: Product[];
  isLoading: boolean;
  error: string | null;
  fetchProducts: () => Promise<void>;
}

const useProductStore = create<ProductStore>((set) => ({
  products: [],
  isLoading: false,
  error: null,

  fetchProducts: async () => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch("/api/products");
      const data = await res.json();
      set({ products: data, isLoading: false });
    } catch (error) {
      set({ error: "商品の取得に失敗しました", isLoading: false });
    }
  },
}));

Jotai:アトミックな状態管理

Jotaiは、Recoilに着想を得たアトミックな状態管理ライブラリです。状態を小さな「アトム」の単位で管理し、必要なアトムだけを購読するコンポーネントのみが再レンダリングされます。

基本的な使い方

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";

// プリミティブアトム
const countAtom = atom(0);
const nameAtom = atom("");
const isDarkModeAtom = atom(false);

// 派生アトム(読み取り専用)
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// 派生アトム(読み書き可能)
const uppercaseNameAtom = atom(
  (get) => get(nameAtom).toUpperCase(),
  (get, set, newValue: string) => {
    set(nameAtom, newValue);
  }
);

// コンポーネントでの使用
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const doubleCount = useAtomValue(doubleCountAtom); // 読み取り専用

  return (
    <div>
      <p>カウント: {count} (2倍: {doubleCount})</p>
      <button onClick={() => setCount((c) => c + 1)}>増加</button>
    </div>
  );
}

複雑な状態の管理

import { atom, useAtom, useAtomValue } from "jotai";
import { atomWithStorage } from "jotai/utils";

// localStorageと同期するアトム
const themeAtom = atomWithStorage<"light" | "dark">("theme", "light");

// Todo管理のアトム設計
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

const todosAtom = atom<Todo[]>([]);

// フィルター状態
const filterAtom = atom<"all" | "active" | "completed">("all");

// フィルタリング結果(派生アトム)
const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);

  switch (filter) {
    case "active":
      return todos.filter((t) => !t.completed);
    case "completed":
      return todos.filter((t) => t.completed);
    default:
      return todos;
  }
});

// 統計アトム
const todoStatsAtom = atom((get) => {
  const todos = get(todosAtom);
  return {
    total: todos.length,
    completed: todos.filter((t) => t.completed).length,
    active: todos.filter((t) => !t.completed).length,
  };
});

// アクション用のwrite-onlyアトム
const addTodoAtom = atom(null, (get, set, text: string) => {
  set(todosAtom, (prev) => [
    ...prev,
    { id: Date.now(), text, completed: false },
  ]);
});

const toggleTodoAtom = atom(null, (get, set, id: number) => {
  set(todosAtom, (prev) =>
    prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
  );
});

// コンポーネント
function TodoApp() {
  const filteredTodos = useAtomValue(filteredTodosAtom);
  const stats = useAtomValue(todoStatsAtom);
  const [filter, setFilter] = useAtom(filterAtom);
  const addTodo = useSetAtom(addTodoAtom);
  const toggleTodo = useSetAtom(toggleTodoAtom);

  return (
    <div>
      <p>全{stats.total}件 / 完了{stats.completed}件 / 未完了{stats.active}件</p>
      <div>
        {(["all", "active", "completed"] as const).map((f) => (
          <button key={f} onClick={() => setFilter(f)} disabled={filter === f}>
            {f}
          </button>
        ))}
      </div>
      {filteredTodos.map((todo) => (
        <div key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.completed ? "✓" : "○"} {todo.text}
        </div>
      ))}
    </div>
  );
}

TanStack Query:サーバー状態管理の決定版

TanStack Query(旧React Query)は、サーバーから取得するデータの管理に特化したライブラリです。キャッシュ、バックグラウンド再取得、楽観的更新などを自動で処理します。

基本的な使い方

import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } from "@tanstack/react-query";

// QueryClientの設定
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5分間はキャッシュを新鮮とみなす
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
});

// App ラッパー
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserList />
    </QueryClientProvider>
  );
}

// データ取得関数
async function fetchUsers(params?: { page?: number; role?: string }): Promise<{
  users: User[];
  total: number;
}> {
  const query = new URLSearchParams();
  if (params?.page) query.set("page", String(params.page));
  if (params?.role) query.set("role", params.role);

  const res = await fetch(`/api/users?${query}`);
  if (!res.ok) throw new Error("ユーザーの取得に失敗しました");
  return res.json();
}

// useQueryによるデータ取得
function UserList() {
  const [page, setPage] = useState(1);

  const { data, isLoading, error, isFetching } = useQuery({
    queryKey: ["users", { page }],
    queryFn: () => fetchUsers({ page }),
  });

  if (isLoading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error.message}</p>;

  return (
    <div>
      {isFetching && <span>更新中...</span>}
      {data?.users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
      <button onClick={() => setPage((p) => p + 1)}>次のページ</button>
    </div>
  );
}

useMutationによるデータ更新

function CreateUserForm() {
  const queryClient = useQueryClient();

  const createMutation = useMutation({
    mutationFn: async (newUser: UserCreateInput) => {
      const res = await fetch("/api/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newUser),
      });
      if (!res.ok) throw new Error("ユーザーの作成に失敗しました");
      return res.json();
    },
    // 成功時にユーザー一覧のキャッシュを無効化
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  const handleSubmit = (formData: UserCreateInput) => {
    createMutation.mutate(formData);
  };

  return (
    <form onSubmit={/* ... */}>
      {createMutation.isPending && <p>作成中...</p>}
      {createMutation.isError && <p>エラー: {createMutation.error.message}</p>}
      {createMutation.isSuccess && <p>作成しました</p>}
      {/* フォームフィールド */}
    </form>
  );
}

楽観的更新

const toggleTodoMutation = useMutation({
  mutationFn: async ({ id, completed }: { id: number; completed: boolean }) => {
    const res = await fetch(`/api/todos/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ completed }),
    });
    return res.json();
  },

  // 楽観的更新:APIレスポンスを待たずにUIを更新
  onMutate: async ({ id, completed }) => {
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);

    queryClient.setQueryData<Todo[]>(["todos"], (old) =>
      old?.map((t) => (t.id === id ? { ...t, completed } : t))
    );

    return { previousTodos };
  },

  // エラー時にロールバック
  onError: (err, variables, context) => {
    if (context?.previousTodos) {
      queryClient.setQueryData(["todos"], context.previousTodos);
    }
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

ライブラリの使い分け指針

選定フローチャート

サーバーから取得するデータの管理が主な目的なら → TanStack Query。キャッシュ、再取得、楽観的更新が自動で処理されるため、自前で実装する必要がありません。

グローバルなクライアント状態が必要なら → Zustand。認証情報、UI設定、カート情報など、複数のコンポーネントで共有する状態に最適です。

きめ細かいレンダリング最適化が必要なら → Jotai。アトム単位の購読により、変更されたアトムに関連するコンポーネントだけが再レンダリングされます。

小規模な状態管理なら → useState/useContext。外部ライブラリが不要で、最もシンプルです。

推奨する組み合わせパターン

// 最もよくある組み合わせ
// TanStack Query(サーバー状態) + Zustand(クライアント状態)

// Zustand: UIの状態管理
const useUIStore = create<UIStore>((set) => ({
  sidebarOpen: false,
  theme: "light",
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  setTheme: (theme: string) => set({ theme }),
}));

// TanStack Query: サーバーデータの管理
function Dashboard() {
  const sidebarOpen = useUIStore((s) => s.sidebarOpen);

  const { data: stats } = useQuery({
    queryKey: ["dashboard-stats"],
    queryFn: fetchDashboardStats,
    refetchInterval: 30000, // 30秒ごとに自動更新
  });

  const { data: notifications } = useQuery({
    queryKey: ["notifications"],
    queryFn: fetchNotifications,
  });

  return (
    <div>
      {sidebarOpen && <Sidebar />}
      <main>
        <StatsCards stats={stats} />
        <NotificationList notifications={notifications} />
      </main>
    </div>
  );
}

まとめ

2026年のReact状態管理は、「状態の種類に応じたツールの使い分け」が基本方針です。

Zustandは、シンプルなAPIでグローバル状態を管理できる万能ツールです。Reduxから移行する場合の第一候補として適しています。ミドルウェアによる永続化やDevTools連携も充実しています。

Jotaiは、アトミックな状態管理でRecoil的なアプローチを好む場合に適しています。派生アトムによるきめ細かいレンダリング最適化が強みです。

TanStack Queryは、サーバー状態の管理に特化した決定版です。キャッシュ、バックグラウンド再取得、楽観的更新などを自動化し、データ取得まわりのコードを大幅に削減できます。

多くのプロジェクトでは、TanStack Query(サーバー状態)+ Zustand(クライアント状態)の組み合わせがバランスの良い選択となります。プロジェクトの規模と要件に応じて、最適な組み合わせを選んでください。

#React#状態管理#Zustand
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう