Reduxの基礎を理解する

コミューンに今月入社した磯村です。

コミューンのフロントエンドは状態管理にReduxを使っています。1 今回Reduxの基本的な動作について調べたので、自分なりに理解した内容と理解するためにやったことをまとめました。

reduxで何ができるようになるか

ReduxはReact等の仮想DOMを利用するUIフレームワークでグローバルなデータの参照・更新に利用されます。これらのフレームワークは木構造でデータを管理するため、親から子へデータを受け渡したり親の状態を更新することは簡単ですが異なる親を持つデータ等の木構造上で離れた位置にあるデータを参照したり更新することが難しいです。これを解決するために多数の箇所で利用するデータをUIフレームワークとは別の場所に保持して、何処からでも参照・更新できるようにするのがReduxです。

プログラムの全体から参照され、プログラムの全体から更新することが可能なものとして悪名高いグローバル変数が挙げられると思います。Reduxは状態(State)と更新処理(Reducer)をStoreに閉じ込めて一定の手順でしか更新できないようにすることで、グローバル変数と異なりどのような状態であるかを推測可能なものにしています。

reduxを利用する時の処理の流れ

ReactのコンポーネントがReduxのStoreを利用する時の処理の流れの概要を図にしました。

ReactコンポーネントがReduxを利用する時の図

図の処理の流れを説明すると次のようになります。

  1. (事前に)コンポーネントがuseSelector等を通してStoreにListenerを登録する
  2. ComponentがStoreにActionを渡す(dispatch)
  3. Reducerが渡されたActionと前のStateから次のStateを計算する
  4. 登録されたListenerが呼び出される
  5. Listenerは新しいStateを参照し、Componentに値を渡して再レンダリングする
  6. 2.に戻る

ReduxのStoreを直接動かす

実際に手で動かさないと実感しにくいところがあるので、ブラウザのコンソールからReduxを動かして動作を確認しました。

まずブラウザの開発者ツールを起動して、下記のコードを入力してReduxをページに読み込みました。

const reduxScriptElement = document.createElement('script');
reduxScriptElement.setAttribute('src', 'https://npmcdn.com/redux/dist/redux.js');
document.head.appendChild(reduxScriptElement);

その後、下記のコードでStoreを用意しました。 このコードはStateをcountというプロパティを持つオブジェクトとして、{ type: "INCREMENT" } というActionをdispatchするとcountの値を1加算するReducerを作成します。 そして、そのReducerを保持するStoreを作成した後、Stateをconsole.logで出力するListenerを登録しています。

const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
};
const store = window.Redux.createStore(reducer);

store.subscribe(() => console.log("current state: ", store.getState()))

Storeの準備が出来た後は下記のようにActionをStoreに渡してLisnterが動作することを確認しました。{ type: "HOGE"} というActionの処理は先程作成したReducerには書いてないのでStateは変化しません。{ type: "INCREMENT"} を渡すとcountの値が1加算されます。

store.dispatch({ type: "HOGE" }) // current state: { count: 0 }
store.dispatch({ type: "HOGE" }) // current state: { count: 0 }
store.dispatch({ type: "INCREMENT" }) // current state: { count: 1 }
store.dispatch({ type: "INCREMENT" }) // current state: { count: 2 }
// ...

ReduxのStoreをコンポーネントから直接動かす

次に、Reactから実際に利用するコードを書きました。 今回は理解のためにreact-reduxで提供されているreactからreduxを利用するためのユーティリティを使わずに 直接Reduxを参照・更新して、Reduxの状態によってコンポーネントを再レンダリングさせてみました。 主なコードは下記の通りとなります。

import React, { useEffect, useState } from "react";
import "./App.css";
import { store } from "./store";

const dispatch = store.dispatch;

function App() {
  const [count, setCount] = useState(store.getState().count);
  useEffect(() => {
    let oldCount = count;
    store.subscribe(() => {
      const currentCount = store.getState().count;
      if (oldCount === currentCount) return;

      oldCount = currentCount;
      setCount(currentCount);
    });
  }, []);

  return (
    <div className="App">
      <div className="count">count: {count}</div>
      <div className="button" onClick={(_e) => dispatch({ type: "INCREMENT" })}>
        INCREMENT
      </div>
      <div className="button" onClick={(_e) => dispatch({ type: "FOOBAR" })}>
        FOOBAR
      </div>
    </div>
  );
}

export default App;

Storeは先程の例で用意したものと同じ定義のものを使っています。 useEffectの中ではStoreから最新のcountを取得してsetCountに渡す関数をstoreに登録しています。 これでdispatchを通してstoreにactionが渡された後、countが更新されていた場合にのみAppコンポーネントが再レンダリングされます。

動くサンプルはこちら

useSelectorを自作してみる

ReactでReduxを使うときに何が行われているかおおよそ把握できたので、最後にreact-reduxで提供されているuseSelectorを自作してみました。

import React, { useEffect, useState } from "react";
import "./App.css";
import { RootState, store } from "./store";

const dispatch = store.dispatch;
const defaultEqualityFn = (a: unknown, b: unknown) => a === b;

function useSelector<TSelected = unknown>(
  selector: (state: RootState) => TSelected,
  equalityFn: (left: TSelected, right: TSelected) => boolean = defaultEqualityFn
): TSelected {
  const [selected, setSelected] = useState(selector(store.getState()));

  useEffect(() => {
    let oldSelected: TSelected;
    store.subscribe(() => {
      const currentSelected = selector(store.getState());

      if (equalityFn(selected, currentSelected)) return;

      oldSelected = currentSelected;
      setSelected(currentSelected);
    });
  }, []);

  return selected;
}

function App() {
  const count = useSelector((state) => state.count);

  return (
    <div className="App">
      <div className="count">count: {count}</div>
      <div className="button" onClick={(_e) => dispatch({ type: "INCREMENT" })}>
        INCREMENT
      </div>
      <div className="button" onClick={(_e) => dispatch({ type: "FOOBAR" })}>
        FOOBAR
      </div>
    </div>
  );
}

export default App

実際のuseSelectorと同様にselectorとequalityFnを受け取り、Stateからの値の取り出しと比較を行うようにしています。 残りの処理は先程のコンポーネントから直接Storeを参照するコードのuseStateとuseEffectの処理とほぼ同じです。

本家のものとは大分違う実装になっていますが、Listenerがコンポーネントの再レンダリングを行う点において同じことをしています。

動くサンプルはこちら

終わりに

普段Reduxを扱う際にはredux-sagaやreact-reduxを通じて使うことが多いのでReduxが何をしているのか曖昧な理解をしていたのですが、今回調べたことで大分見通しがよくなったように思います。