🎉

Next.js/React向け状態管理ライブラリ(Redux・Zustand・Jotai)の比較と選定

に公開

はじめまして、_minoです!

この記事では、フロントエンド開発における状態管理ライブラリのトレンドと、Next.js/Reactに最適な選択肢について比較・検証した内容をまとめています。

技術選定の参考になれば幸いです💪

🚀 状態管理ライブラリ

Redux Toolkit

Redux Toolkitは、Reduxの公式ツールセットです。従来のReduxの問題点(複雑な設定、大量の定型コード)を解決するために作られました。

特徴

  • configureStore()により、最適な設定が自動的に適用されるストアを作成できる
  • createSlice()で、アクションとリデューサーを一度に定義できる
  • Immerが統合されており、不変更新を直感的なコードで記述できる(Immer: 状態を直接変更しているような書き方でも、安全に新しい状態を作成してくれるライブラリ)
  • RTK Query により、データ取得とキャッシュ機能を提供
  • TypeScriptを完全サポートしており、型推論が充実

https://redux-toolkit.js.org/

Zustand

Zustandは、シンプルな実装で軽量な状態管理ライブラリです。Reduxと比べて定型コードが少なく、Context APIのようなProviderも不要で使えます。

特徴

  • シンプルなAPIで、create()関数を1回呼ぶだけで状態管理を開始できる
  • Providerが不要なため、アプリ全体を特別なコンポーネントで囲む必要がない
  • 小さなファイルサイズで軽量、必要な機能だけを読み込める
  • 選択的な再レンダリングにより、必要なデータが変わったコンポーネントだけが更新される
  • React以外でも使用可能で、フレームワークに依存しない設計

https://zustand-demo.pmnd.rs/

Jotai

Jotaiは、小さな状態の単位(atom)を組み合わせて複雑な状態を作るライブラリです。必要な部分だけ独立したatomとして定義し、それらを組み合わせて状態を構成します。

特徴

  • Atomic設計により、状態を小さな独立した単位(atom)に分割して管理
  • 非同期処理のサポートがあり、データ取得などの非同期処理を自然に扱える
  • React Suspenseとの統合により、ローディング状態を宣言的に管理できる
  • フレームワーク非依存のコアで、React以外でも使える設計
  • メモリ管理が最適化されており、不要になった状態を自動的にクリーンアップ

https://jotai.org/

その他

他にも以下のような状態管理ライブラリがあります。
詳細はこちらからご確認ください!

https://valtio.dev/
https://recoiljs.org/

📚 トレンド・注目度

GitHubスター数

Star Historyチャート(GitHubスター数の推移)はこちらになります。

Zustandの人気が年々増加しています。シンプルな実装かつ軽量であるので今後もどんどん人気が出てきそうです。

Jotaiも徐々に増加している傾向がありそうです。Atomic設計による柔軟な状態管理React Suspenseとの統合の良さが評価されています。

Redux Toolkitは成熟したライブラリとして安定していますが、新規プロジェクトでの採用は横ばいまたは微減傾向にあります。大規模プロジェクトや既存のReduxコードベースでは依然として主流ですが、より軽量でシンプルな選択肢(ZustandやJotai)の台頭により、中小規模のプロジェクトでは選ばれにくくなっています。

📊 比較検証・ベンチマーク

実プロジェクトを想定した環境で、各ライブラリの「バンドルサイズ」、「パフォーマンス」、「メモリ使用量」を計測・比較しました。

計測環境

  • Node.js: v22.20.0
  • Bun: 1.2.12(パッケージマネージャー)
  • Next.js: 15.5.4
  • React: 19.1.0
  • TypeScript: 5.x
  • ビルドツール: Turbopack / Webpack

バンドルサイズ比較

ライブラリ First Load JS 基準との差分
Zustand 118 kB 基準(最軽量)
Jotai 121 kB +3 kB (+2.5%)
Redux Toolkit 128 kB +10 kB (+8.5%)

パフォーマンス比較

ライブラリ データ生成時間 個別更新時間(500 回) 基準との差分
Jotai 1.10 ms 8.80 ms 1 倍(基準)
Zustand 0.30 ms 11.90 ms 1.4 倍
Redux Toolkit 3.80 ms 191.60 ms 22 倍遅い

メモリ使用量比較

ライブラリ データ生成時 個別更新後(500回) 更新による追加
Redux Toolkit 161.19 KB 22.03 MB +319.50 KB
Jotai 413.49 KB 19.93 MB +2.50 MB
Zustand 477.39 KB 28.46 MB +98.25 KB

比較まとめ

ライブラリ バンドルサイズ 更新速度 メモリ効率(生成時) メモリ効率(更新時)
Zustand ◎ 最軽量 ○ 高速 △ 普通 ◎ 最効率
Jotai ○ 軽量 ◎ 最速 ○ 良好 △ 大きめ
Redux Toolkit △ 普通 △ 遅め ◎ 最効率 ○ 良好

※あくまで相対的な評価であり、検証時の比較結果ですので、状況によっては異なる可能性があります。

🧑‍💻 実装方法の比較

「クイズの正誤判定」を例に、それぞれの実装方法について比較していきます。
共通のデータ構造として、以下の4つを使用していきます。

interface QuizState {
  currentQuestionIndex: number;  // 現在の問題番号 (0-2)
  selectedAnswers: number[];      // 選択した回答 [1, 0, 2] のような配列
  showResults: boolean;           // 結果表示フラグ
  score: number;                  // 得点
}

Redux Toolkitの実装方法

createSliceを使用してアクションとリデューサーを一箇所で定義します。Immerが組み込まれているため、直接状態を書き換える形で書けます。

Sliceの定義

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface QuizState {
  currentQuestionIndex: number;
  selectedAnswers: number[];
  showResults: boolean;
  score: number;
}

const initialState: QuizState = {
  currentQuestionIndex: 0,
  selectedAnswers: [],
  showResults: false,
  score: 0
};

// createSlice: アクションとリデューサーを自動生成
const quizSlice = createSlice({
  name: 'quiz',
  initialState,
  reducers: {
    selectAnswer: (state, action: PayloadAction<{ questionIndex: number; answerIndex: number }>) => {
      const { questionIndex, answerIndex } = action.payload;
      state.selectedAnswers[questionIndex] = answerIndex;
    },
    
    nextQuestion: (state) => {
      if (state.currentQuestionIndex < 2) {
        state.currentQuestionIndex += 1;
      } else {
        state.showResults = true;
      }
    },
    
    previousQuestion: (state) => {
      if (state.currentQuestionIndex > 0) {
        state.currentQuestionIndex -= 1;
      }
    },
    
    calculateScore: (state, action: PayloadAction<number[]>) => {
      const correctAnswers = action.payload;
      state.score = state.selectedAnswers.reduce((score, answer, index) => {
        return answer === correctAnswers[index] ? score + 1 : score;
      }, 0);
    },
    
    resetQuiz: () => initialState
  }
});

export const { 
  selectAnswer, 
  nextQuestion, 
  previousQuestion, 
  calculateScore, 
  resetQuiz 
} = quizSlice.actions;

export default quizSlice.reducer;

Storeの定義

import { configureStore } from '@reduxjs/toolkit';
import quizReducer from './quizSlice';

// configureStore: Redux DevToolsとミドルウェアを自動設定
export const store = configureStore({
  reducer: {
    quiz: quizReducer
  }
});

// 型推論用のヘルパー型
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

コンポーネントの定義

import { useSelector, useDispatch } from 'react-redux';
import { selectAnswer, nextQuestion, previousQuestion } from './quizSlice';
import type { RootState } from './store';

function QuizComponent() {
  const dispatch = useDispatch();
  // useSelectorで必要な状態のみ取得
  const { currentQuestionIndex, selectedAnswers, showResults } = useSelector(
    (state: RootState) => state.quiz
  );

  const handleSelectAnswer = (answerIndex: number) => {
    dispatch(selectAnswer({ 
      questionIndex: currentQuestionIndex, 
      answerIndex 
    }));
  };

  const handleNext = () => {
    dispatch(nextQuestion());
  };

  const handlePrevious = () => {
    dispatch(previousQuestion());
  };
}

Zustandの実装方法

Storeの定義
stateとactionsを1つのオブジェクトにまとめて定義します。immerミドルウェアを使用することで、Redux Toolkitと同様に直接書き換える形で状態更新ができます。

Storeの定義

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface QuizState {
  // State
  currentQuestionIndex: number;
  selectedAnswers: number[];
  showResults: boolean;
  score: number;
  
  // Actions
  selectAnswer: (questionIndex: number, answerIndex: number) => void;
  nextQuestion: () => void;
  previousQuestion: () => void;
  calculateScore: (correctAnswers: number[]) => void;
  resetQuiz: () => void;
}

const initialState = {
  currentQuestionIndex: 0,
  selectedAnswers: [],
  showResults: false,
  score: 0
};

// create + immer: 状態とアクションを1つのストアに
const useQuizStore = create<QuizState>()(
  immer((set, get) => ({
    // ===== State =====
    ...initialState,
    
    // ===== Actions =====
    selectAnswer: (questionIndex, answerIndex) => {
      set((state) => {
        state.selectedAnswers[questionIndex] = answerIndex;
      });
    },
    
    nextQuestion: () => {
      set((state) => {
        if (state.currentQuestionIndex < 2) {
          state.currentQuestionIndex += 1;
        } else {
          state.showResults = true;
        }
      });
    },
    
    previousQuestion: () => {
      set((state) => {
        if (state.currentQuestionIndex > 0) {
          state.currentQuestionIndex -= 1;
        }
      });
    },
    
    calculateScore: (correctAnswers) => {
      // get(): 現在の状態を読み取り
      const selectedAnswers = get().selectedAnswers;
      const score = selectedAnswers.reduce((acc, answer, index) => {
        return answer === correctAnswers[index] ? acc + 1 : acc;
      }, 0);
      set((state) => {
        state.score = score;
      });
    },
    
    resetQuiz: () => {
      set(() => initialState);
    },
  }))
);

コンポーネントの定義

import { useQuizStore } from './quizStore';

function QuizComponent() {
  // 必要な状態とアクションを直接取得(セレクタ関数)
  const { 
    currentQuestionIndex, 
    selectedAnswers, 
    showResults,
    selectAnswer,
    nextQuestion,
    previousQuestion
  } = useQuizStore();

  const handleSelectAnswer = (answerIndex: number) => {
    selectAnswer(currentQuestionIndex, answerIndex);
  };

  const handleNext = () => {
    nextQuestion();
  };

  const handlePrevious = () => {
    previousQuestion();
  };
}

Jotaiの実装方法

状態をatom単位で細かく分割して管理します。アクション的な処理は書き込み専用atom(write-only atom)として定義します。これは第一引数をnullにし、第二引数で他のatomを更新する関数を定義します。

Atomsの定義

import { atom } from 'jotai';

// ===== 状態Atoms =====
// 各atomが独立した状態の単位
export const currentQuestionIndexAtom = atom(0);
export const selectedAnswersAtom = atom<number[]>([]);
export const showResultsAtom = atom(false);
export const scoreAtom = atom(0);
export const correctAnswersAtom = atom([0, 1, 2]);

// ===== アクションAtoms(書き込み専用) =====
// 第一引数null = read関数なし、writeのみ
export const selectAnswerAtom = atom(
  null,
  (get, set, update: { questionIndex: number; answerIndex: number }) => {
    const answers = [...get(selectedAnswersAtom)];
    answers[update.questionIndex] = update.answerIndex;
    set(selectedAnswersAtom, answers);
  }
);

export const nextQuestionAtom = atom(
  null,
  (get, set) => {
    const current = get(currentQuestionIndexAtom);
    if (current < 2) {
      set(currentQuestionIndexAtom, current + 1);
    } else {
      set(showResultsAtom, true);
    }
  }
);

export const previousQuestionAtom = atom(
  null,
  (get, set) => {
    const current = get(currentQuestionIndexAtom);
    if (current > 0) {
      set(currentQuestionIndexAtom, current - 1);
    }
  }
);

export const calculateScoreAtom = atom(
  null,
  (get, set) => {
    const selectedAnswers = get(selectedAnswersAtom);
    const correctAnswers = get(correctAnswersAtom);
    const score = selectedAnswers.reduce((acc, answer, index) => {
      return answer === correctAnswers[index] ? acc + 1 : acc;
    }, 0);
    set(scoreAtom, score);
  }
);

export const resetQuizAtom = atom(
  null,
  (get, set) => {
    set(currentQuestionIndexAtom, 0);
    set(selectedAnswersAtom, []);
    set(showResultsAtom, false);
    set(scoreAtom, 0);
  }
);

コンポーネントの定義

import { useAtom, useSetAtom } from 'jotai';
import { 
  currentQuestionIndexAtom,
  selectedAnswersAtom,
  showResultsAtom,
  selectAnswerAtom,
  nextQuestionAtom,
  previousQuestionAtom
} from './quizAtoms';

function QuizComponent() {
  // useAtom: 読み書き両方
  const [currentQuestionIndex] = useAtom(currentQuestionIndexAtom);
  const [selectedAnswers] = useAtom(selectedAnswersAtom);
  const [showResults] = useAtom(showResultsAtom);
  
  // useSetAtom: 書き込みのみ(再レンダリング最適化)
  const selectAnswer = useSetAtom(selectAnswerAtom);
  const nextQuestion = useSetAtom(nextQuestionAtom);
  const previousQuestion = useSetAtom(previousQuestionAtom);

  const handleSelectAnswer = (answerIndex: number) => {
    selectAnswer({ 
      questionIndex: currentQuestionIndex, 
      answerIndex 
    });
  };

  const handleNext = () => {
    nextQuestion();
  };

  const handlePrevious = () => {
    previousQuestion();
  };
}

📌 個人的所感

パフォーマンスやシンプルさを求めるなら、Zustandが特に優れていると感じます。実案件での使用感もかなり良く、メンバーの技術理解も早い印象でしたのでおすすめです。

Jotaiの概念もRecoilに近く好印象で、管理面では小規模開発だとオーバースペック気味ではありますが、パフォーマンスもZustandに並び良好なので選定候補に入れても良さそうです。

Reduxが古株ということもあり、Redux Toolkitの知名度やコミュニティが活発な印象です。Zustand、Jotaiとはパフォーマンス特性が異なりますが、機能面やRTK Queryを使ったフェッチも行う場合はかなり有力な選択肢になりそうです。

👀 おわり

最後まで読んでくださり、ありがとうございました!☺️
この記事を通して、少しでも開発のお役に立てば幸いです!

個人ブログでも「技術選定に関すること」や「最新技術の分析・深掘り」など学びや知見を発信しています。もしご興味のある方はこちらからご確認いただけますと幸いです!
https://techbuild.app/blog

過去の執筆記事
https://zenn.dev/m_noto/articles/4efa5a70f8ca98
https://zenn.dev/m_noto/articles/a2c09f741ba65e
https://zenn.dev/m_noto/articles/a73dc4291983e6

Discussion