はじめに
前回の記事では、props drilling の問題と、それを解決する手段として Context API を紹介しました。 さらに、Context API には Provider Hell や 不要な再レンダリング といった課題があることも見てきました。
そこで今回は、そのような課題を克服し、よりスケーラブルで予測可能な状態管理を可能にするライブラリ――Redux――について説明しようと思います。
Reduxとは?
Reduxは、JavaScriptアプリケーションの状態管理ライブラリで、ReactをはじめとするさまざまなUIフレームワークと組み合わせて利用できます。
「アプリ全体の状態を単一のストアで一元管理する」 というシンプルな原則のもと、以下の3つのコア概念で構成されています。
- Store - アプリ全体の状態を保持する場所
- Action - 状態を更新するためのイベントオブジェクト
- Reducer - Actionの内容に応じて状態を更新する純関数
この説明だけで分かりづらいと思われますので、さっそくコードにて確認しましょう。
プロジェクトの設定
練習プロジェクトの構造
src/
main.jsx
App.jsx
store/
index.js
counter.js
components/
Layout.jsx
Content.jsx
Panel.jsx
CounterOnlyPanel.jsx
CounterDisplay.jsx
CounterControls.jsx
Reduxのインストール
npm install react-redux
今回はReactを使いますので、上記のコマンドでreduxをインストールしました。
公式的にToolkitを使うのを推奨していますが、今回は概念の理解のためにバニラReduxで作成しました。
コード作成
Reducer
// src/store/counter.js
// アクションタイプ
/* アクションの種類を一意に識別するための定数です。
"counter/" というプレフィックスを付けることで、
他のスライスのアクションと名前が衝突するのを防ぎます。*/
const INCREMENT = "counter/INCREMENT";
const DECREMENT = "counter/DECREMENT";
const RESET = "counter/RESET";
// アクションクリエーター
/*
アクションオブジェクトを生成する関数です。
UIコンポーネントはこの関数を呼び出して dispatch() に渡すだけで、
正しい形式のアクションを送信できます。
*/
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
export const reset = () => ({ type: RESET });
// 初期状態
const initialState = { value: 0 };
// リデューサー
/*
現在の状態とアクションを引数に取り、新しい状態を返す純関数です。
不変性(immutability)を守るため、スプレッド構文 { ...state } でコピーしてから値を更新しています。
このreducerはカウンター機能に関するアクションのみを処理します
*/
export default function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { ...state, value: state.value + 1 };
case DECREMENT:
return { ...state, value: state.value - 1 };
case RESET:
return { ...state, value: 0 };
default:
return state;
}
}
このcounter.jsの役割は、グローバル状態のsliceとしてカウンターに関する状態を管理するモジュールの役割をします。
上で説明したようにリデューサーで状態とアクションを引数に取って、新しいvalueを返していることが確認できます。
Store
// src/store/index.js
import { legacy_createStore as createStore, combineReducers } from "redux";
import counter from "./counter";
const rootReducer = combineReducers({
counter,
});
const hasDevtools =
typeof window !== "undefined" && window.__REDUX_DEVTOOLS_EXTENSION__;
const store = createStore(rootReducer, hasDevtools && hasDevtools());
export default store;
store/index.js は、複数のスライスリデューサーを統合し、Reduxの Store を生成するファイルです。
-
combineReducers()
複数のリデューサーを1つのルートリデューサーにまとめます。
今回はstore/counter.js
で定義したカウンター用リデューサーを、store/index.js
でルートリデューサーに統合し、Storeとしてアプリ全体に提供します。 -
legacy_createStore()
Redux Toolkit を使わない場合に Store を生成するための関数です。
(createStore
は非推奨のため、legacy_createStore
を使用しています) -
Redux DevTools との連携
開発時に状態の変化を可視化するためのブラウザ拡張機能に対応させています。
Action
// src/components/CounterControls.jsx
import { useDispatch } from "react-redux";
import { increment, decrement, reset } from "../store/counter";
export default function CounterControls() {
const dispatch = useDispatch();
return (
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(reset())}>Reset</button>
</div>
);
}
-
useDispatch にアクションオブジェクトを渡すことで、Store に「この処理を実行してほしい」という命令を送ります。
-
increment() / decrement() / reset() は、前述の counter.js に定義されている アクションクリエーター(Action Creator) です。
-
Store は受け取ったアクションに応じて対応する リデューサー(Reducer) を実行し、状態(state)を更新します。
このコンポーネントは、ユーザー操作(ボタンクリック) → アクション発行(dispatch) → 状態変更 という流れを担っています。
import { useSelector } from "react-redux";
export default function CounterDisplay() {
const value = useSelector((state) => state.counter.value);
return <div style={{ fontSize: 24, fontWeight: 700 }}>Count: {value}</div>;
}
アクションを発行せず、単純に状態を表示するだけであればuseSelectorを使ってグローバル状態をUIに反映させることができます。
つまり、現在CounterControlsとCounterDisplayの関係は上の画像のようになっています。
もちろん、CounterControls と CounterDisplay は props drilling なしで Store に直接接続されています。
Provider 設定
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import App from "./App.jsx";
import store from "./store";
createRoot(document.getElementById("root")).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);
-
Provider
react-redux が提供するコンポーネントで、子コンポーネント全体に Redux の Store(グローバル状態) を提供します。
この Provider でアプリをラップすることで、useSelector や useDispatch などのフックがアプリのどこからでも利用可能になります。 -
store
store/index.js で作成した Redux Store を Provider の store プロパティに渡しています。これにより、アプリ全体が同じストアインスタンスを共有します。
終わりに
以上、Redux の基本概念である Reducer / Store / Action の流れと、それぞれの役割について説明しました。
プログラミングを始めたばかりの方にとっては、少し難しく感じる部分もあるかもしれませんが、Redux の仕組みに慣れることで、大規模で複雑なアプリケーションにおいても状態管理が明確になり、保守性や拡張性が向上します。
次回は、Redux の公式ドキュメントでも推奨されている Redux Toolkit を使った、より簡潔で実用的な書き方について紹介します。
記事内ではコードを一部省略しましたが、全体の実装は以下の GitHub リポジトリからご確認いただけます。