【React】Redux・Zustand・Context APIを同じアプリで実際に使い比べてみた

JavaScript

こんにちは、かつコーチです。

前回はAPI連携ありのTODOアプリを、useStateとカスタムフックだけで実装しました。

今回はReact編の最終回として、「同じ簡単なアプリを、状態管理の主要な選択肢それぞれで実装したらどう違うのか」を実際に検証してみます。

対象はContext APIRedux ToolkitZustandの3つです。

「結局どれを使えばいいの?」という悩みに対して、コード量と書き心地を実際に比較しながら、私なりの結論を出していきます。

検証の題材とルール

あえてシンプルなカウンターアプリで検証する

複雑なアプリで比較すると、実装の違いよりも題材自体の複雑さが目立ってしまいます。

そこで今回は、あえてシンプルなカウンター機能付きのTODOリスト(TODOの件数をグローバルに参照できる)を題材にしました。

  • TODOの追加・削除ができる
  • 完了していないTODOの件数を、ヘッダー部分に常に表示する(別コンポーネントから同じ状態を参照する)

「複数のコンポーネントから同じ状態を参照・更新する」という、状態管理ライブラリが本領を発揮する場面をあえて用意しています。

比較する観点

今回は次の3つの観点で比較します。

  • コード量:セットアップに必要な記述量
  • 書き心地:状態の更新・参照のしやすさ
  • 学習コスト:新しく覚える概念の多さ

Context APIで実装する

標準機能だけで完結する

Context APIはReact標準の機能なので、追加のライブラリは不要です。

// contexts/TodoContext.tsx
import { createContext, useContext, useState, ReactNode } from "react";

type Todo = { id: string; title: string; completed: boolean };

type TodoContextType = {
  todos: Todo[];
  addTodo: (title: string) => void;
  removeTodo: (id: string) => void;
};

const TodoContext = createContext<TodoContextType | undefined>(undefined);

export const TodoProvider = ({ children }: { children: ReactNode }) => {
  const [todos, setTodos] = useState<Todo[]>([]);

  const addTodo = (title: string) => {
    setTodos((prev) => [...prev, { id: crypto.randomUUID(), title, completed: false }]);
  };

  const removeTodo = (id: string) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  };

  return (
    <TodoContext.Provider value={{ todos, addTodo, removeTodo }}>
      {children}
    </TodoContext.Provider>
  );
};

export const useTodoContext = () => {
  const context = useContext(TodoContext);
  if (!context) throw new Error("TodoProviderの内側で使ってください");
  return context;
};
// components/TodoCountBadge.tsx
import { useTodoContext } from "../contexts/TodoContext";

export const TodoCountBadge = () => {
  const { todos } = useTodoContext();
  const count = todos.filter((t) => !t.completed).length;
  return <span>未完了:{count}件</span>;
};

Providerで全体をラップし、useContextで自作フック経由で値を取り出す、というシンプルな構造です。

Redux Toolkitで実装する

ボイラープレートは大きく減ったが概念は多い

昔のReduxは記述量が膨大でしたが、Redux Toolkit(RTK)の登場でかなり簡潔になりました。

// store/todoSlice.ts
import { createSlice, PayloadAction, configureStore } from "@reduxjs/toolkit";

type Todo = { id: string; title: string; completed: boolean };
type TodoState = { items: Todo[] };

const initialState: TodoState = { items: [] };

const todoSlice = createSlice({
  name: "todo",
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      state.items.push({ id: crypto.randomUUID(), title: action.payload, completed: false });
    },
    removeTodo: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
  },
});

export const { addTodo, removeTodo } = todoSlice.actions;

export const store = configureStore({
  reducer: { todo: todoSlice.reducer },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/TodoCountBadge.tsx
import { useSelector } from "react-redux";
import type { RootState } from "../store/todoSlice";

export const TodoCountBadge = () => {
  const count = useSelector(
    (state: RootState) => state.todo.items.filter((t) => !t.completed).length
  );
  return <span>未完了:{count}件</span>;
};

RTKではstateを直接書き換えているように見えますが、内部でImmerが使われており、実際にはイミュータブルな更新に変換されています。

createSliceconfigureStoreuseSelectorProvider(アプリのルートに配置)と、覚える概念の数はContext APIより多くなります。

Zustandで実装する

Providerが不要でコード量が最小

Zustandは、Providerでラップする必要すらなく、フックを直接importして使えるライブラリです。

// store/useTodoStore.ts
import { create } from "zustand";

type Todo = { id: string; title: string; completed: boolean };

type TodoStore = {
  todos: Todo[];
  addTodo: (title: string) => void;
  removeTodo: (id: string) => void;
};

export const useTodoStore = create<TodoStore>((set) => ({
  todos: [],
  addTodo: (title) =>
    set((state) => ({
      todos: [...state.todos, { id: crypto.randomUUID(), title, completed: false }],
    })),
  removeTodo: (id) =>
    set((state) => ({ todos: state.todos.filter((t) => t.id !== id) })),
}));
// components/TodoCountBadge.tsx
import { useTodoStore } from "../store/useTodoStore";

export const TodoCountBadge = () => {
  const count = useTodoStore((state) => state.todos.filter((t) => !t.completed).length);
  return <span>未完了:{count}件</span>;
};

create関数でストアを作るだけで完結しており、Providerもアクションの定義ファイルを分ける必要もありません。

比較結果:コード量・書き心地・学習コスト

実際にファイル数・行数を数えてみた

同じ機能を実装したときの、実際のファイル構成を比較してみます。

観点Context APIRedux ToolkitZustand
必要なファイル数2(Context本体+Provider)3(Slice+Store+Provider配置)1(Store本体のみ)
Providerの要否必要必要不要
コード量の体感中程度やや多い最小
状態更新の書き方setTodosを自作関数でラップstate.items.push(Immer経由)set関数で新しい状態を返す
DevToolsでの追跡標準では不可標準対応(強力)対応(拡張機能経由)
学習コスト低い(React標準知識で足りる)高い(Slice・Store・Selectorの概念)低い(フックの延長で理解できる)

実際に手を動かして感じたこと

私が個人的に一番驚いたのは、Zustandのコード量の少なさです。

Providerが不要という点は、コンポーネントツリーの深い位置に急いで状態を追加したいときに特に効いてきます。

一方でRedux Toolkitは、コード量こそ増えますが、Redux DevToolsによる「いつ・どのアクションで・どう状態が変わったか」の追跡機能が非常に強力です。

大規模なチーム開発で「バグの原因になった状態変化を後から追いたい」というニーズがあるなら、この追跡性の高さは大きな価値になります。

Context APIは学習コストが一番低い反面、Contextの値が更新されるたびに、そのContextを参照している全コンポーネントが再レンダリングされるという弱点があります。

今回のTODOカウンター程度の規模なら問題になりませんが、頻繁に更新される大きな状態をContext APIだけで扱うと、パフォーマンスの問題に直面しやすくなります。

つまずきやすいポイント:Context APIの再レンダリング問題

「なぜか関係ないコンポーネントまで再描画される」

以前、Context APIだけでアプリ全体の状態を管理しようとしたとき、TODOの追加とは無関係なはずのヘッダーコンポーネントまで、TODOを追加するたびに再レンダリングされていることにReact DevToolsで気づいて驚いたことがあります。

❌ Before:1つのContextに多くの状態をまとめてしまう

// 検索キーワード・ユーザー情報・TODOリストなど、
// 関連性の薄い状態を1つのContextに全部詰め込んでいる
type AppContextType = {
  todos: Todo[];
  addTodo: (title: string) => void;
  searchKeyword: string;
  setSearchKeyword: (keyword: string) => void;
  currentUser: User | null;
};

このContextを使っている限り、検索キーワードを変更しただけでも、TODOリストしか見ていないコンポーネントまで再レンダリングされてしまいます。

✅ After:関心ごとにContextを分割する

// TODOに関するものだけをTodoContextに
const TodoContext = createContext<TodoContextType | undefined>(undefined);

// 検索に関するものだけをSearchContextに分離する
const SearchContext = createContext<SearchContextType | undefined>(undefined);

Contextを関心ごとに分割しておくことで、検索キーワードの変更がTODOリストの再レンダリングに波及しなくなります。

これは知っているだけで防げる典型的なつまずきポイントなので、Context APIを使う際は最初から「1つのContext=1つの関心ごと」を意識しておくのがおすすめです。

結論:どう選べばいいか

3つの選択肢の使い分け

今回の検証を踏まえた、私なりの判断軸をまとめておきます。

  • Context API:状態の更新頻度が低い(テーマ・ログインユーザー情報など)、かつ小〜中規模のアプリ
  • Zustand:中規模アプリで、シンプルさと十分な機能のバランスを取りたいとき
  • Redux Toolkit:大規模・複数チームでの開発で、状態変化の追跡性やエコシステムの充実を重視するとき

「まず小さく始めて、必要になったら段階的に強力なツールへ移行する」という発想が現実的です。

個人開発や小規模なプロジェクトであれば、Zustandから始めてみるのが今の私のおすすめです。

まとめ

React編ではPHPと同じように、Reactとは何かという基礎から、JSX・Props・useStateをはじめとするHooks、API連携、認証、テスト、デプロイ、そして今回の設計・状態管理まで、50記事を通して一通り学んできました。

最初は「HTMLと何が違うんだろう」というレベルからのスタートだったPHP編の読者の方が、この記事にたどり着く頃には、状態管理ライブラリの選定について自分なりの判断軸を持てるようになっている——そんな成長のきっかけになっていたら、これほど嬉しいことはありません。

この記事のポイント

  • Context APIは学習コストが低い反面、Contextの粒度が大きいと不要な再レンダリングが起きやすい
  • Redux Toolkitはコード量こそ増えるが、DevToolsによる状態変化の追跡性が強力
  • ZustandはProviderが不要でコード量が最小、中規模アプリとの相性が良い
  • 「1つのContext=1つの関心ごと」を意識すると、Context APIの再レンダリング問題を防げる
  • まず小さく始めて、規模に応じて段階的に強力なツールへ移行する発想が現実的

次のステップへ

React編50記事、お疲れさまでした。

ここまで一緒に学んできてくださった読者の方には、本当に感謝しかありません。

次はVue.js編に進みます。

ReactとVue.jsは思想も書き方も異なる部分が多いので、Reactとの対比を意識しながら書いていくつもりです。

「Reactではこう書いていたけど、Vueだとどう変わるんだろう」という視点を持ちながら読んでもらえると、理解がより深まるはずです。

公開までもう少しお待ちください。楽しみにしていてくださいね。

タイトルとURLをコピーしました