30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

store.replaceReducer で reducer を入れ替える

Redux では原則として、ひとつのアプリに対してひとつのstoreを作成する。
アプリ内に複数のstoreを共存させることも可能ではあるが、基本的には単一のstoreを利用するべきであり、公式ドキュメントでもそのように説明している。

Store Setup | Redux
Style Guide | Redux

storeが肥大化し、ドメイン毎に実装を細分化したくなった場合は、storeではなくreducerをドメイン毎に分割する。そしてそれをcombineReducersでまとめるのが一般的。
そのため、ひとつのアプリのなかで複数のstoreを使い回す、ということはしない。

stateの形状やreducerを変化させたい場合は、replaceReducerを使う。つまり、引き続き同じstoreを使いつつ、reducerを入れ替えることで対応する。
replaceReducerは主に、reducerを動的に追加したり、ホットリロードを有効にしたりするために、使用される。

この記事ではまずreplaceReducerの挙動を確認し、その後、ホットリロードを有効にする方法を見ていく。

この記事で使っているライブラリのバージョンは以下の通り。

  • redux@4.0.5
  • react@16.13.1
  • react-redux@7.2.0
  • webpack-dev-server@3.10.3

replaceReducer の挙動

サンプルとしてまず、単純な Redux アプリを作った。

import {createStore} from 'redux';

const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

const plusMinus = (state = 0, action) => {
  const {type, amount} = action;
  switch (type) {
    case INCREMENT:
      return state + amount;
    case DECREMENT:
      return state - amount;
    default:
      return state;
  }
};

const store = createStore(plusMinus);

store.subscribe(() => console.log(store.getState()));

store.dispatch({type: INCREMENT, amount: 5}); // 5
store.dispatch({type: DECREMENT, amount: 3}); // 2

plusMinusというreducercreateStoreに渡して、storeを作成している。
subscribeに渡した関数はdispatchする度に呼び出されるので、dispatchした結果stateの値がどうなったのかを、ログで確認できる。

このコードに、以下のreducerを追加する。

const MULTIPLICATION = 'MULTIPLICATION';

const multiplication = (state = 0, action) => {
  const {type, amount} = action;
  switch (type) {
    case MULTIPLICATION:
      return state * amount;
    default:
      return state;
  }
};

このreducerは、action.typeMULTIPLICATIONのときは乗算を行い、それ以外のときはstateをそのまま返す。

reducerとしてplusMinusを渡し、途中でmultiplicationに入れ替えたのが、以下のコード。

const store = createStore(plusMinus);

store.subscribe(() => console.log(store.getState()));

store.dispatch({type: INCREMENT, amount: 5}); // 5
store.dispatch({type: DECREMENT, amount: 3}); // 2

store.replaceReducer(multiplication); // 2

// reducer が multiplication に変わっているので INCREMENT は実行されず state がそのまま返される
store.dispatch({type: INCREMENT, amount: 4}); // 2

store.dispatch({type: MULTIPLICATION, amount: 3}); // 6

replaceReducerを実行すると、内部的にアクションのdispatchが行われる。そのため、subscribeで設定した関数がこのタイミングでも実行される。
そしてその結果を見ると、stateの値は維持されているのが分かる。それまでの値(今回は2)が引き継がれる。

そして、reducermultiplicationに変わっているため、typeINCREMENTactionを渡しても、stateは変化しない。

state はそのまま維持される

既に見たように、replaceReducerを実行してもstateはそのまま維持される。これを理解せずに実装を行うと、不具合を生んでしまう可能性がある。

先程の例ではplusMinusmultiplication、どちらのreducerも同じ形状のstateを想定していたため、問題にならなかった。
つまりstate: numberである。

異なる形状のstateを想定しているreducerで入れ替えてしまうと、多くの場合問題が発生する。

例として、idsusersという 2 つのreducerを用意した。

const ADD_ID = 'ADD_ID';

const ids = (state = {ids: []}, action) => {
  const {type, id} = action;
  switch (type) {
    case ADD_ID:
      return {
        ids: [...state.ids, id],
      };
    default:
      return state;
  }
};

const ADD_USER = 'ADD_USER';

const users = (state = {users: []}, action) => {
  const {type, user} = action;
  switch (type) {
    case ADD_USER:
      return {
        users: [...state.users, user],
      };
    default:
      return state;
  }
};

idsstate: {ids: number[]}を想定し、usersstate: {users: string[]}を想定している。

最初にidsreducerとして設定し、そのあとでusersに入れ替えてみる。すると、エラーが発生する。

const store = createStore(ids);

store.subscribe(() => console.log(store.getState()));

store.dispatch({type: ADD_ID, id: 3}); // { ids: [ 3 ] }
store.dispatch({type: ADD_ID, id: 4}); // { ids: [ 3, 4 ] }

store.replaceReducer(users); // { ids: [ 3, 4 ] }

store.dispatch({type: ADD_USER, user: 'Alice'}); // TypeError: state.users is not iterable

replaceReducerによってreducerを入れ替えても、idsによって作られたstateはそのまま維持されている。
そのため、{type: ADD_USER, user: 'Alice'}dispatchすると、引数state{ ids: [ 3, 4 ] }が格納された状態で、usersを実行する。
そしてcase: ADD_USERブロックの内容を実行するが、state.usersは存在しない、つまりundefinedである。
そしてundefinedに対してスプレッド構文を使おうとするため、エラーになる。

combineReducersを使用した場合、stateオブジェクトのキー毎にreducerを設定することになるので、この問題は発生しない。
reducerstate全体ではなく自身が担当するstateのみを受け取るので、それに合わせてidsusersも書き直している。

import {createStore, combineReducers} from 'redux';

const ADD_ID = 'ADD_ID';

const ids = (state = [], action) => {
  const {type, id} = action;
  switch (type) {
    case ADD_ID:
      return [...state, id];
    default:
      return state;
  }
};

const ADD_USER = 'ADD_USER';

const users = (state = [], action) => {
  const {type, user} = action;
  switch (type) {
    case ADD_USER:
      return [...state, user];
    default:
      return state;
  }
};

const store = createStore(combineReducers({ids}));

store.subscribe(() => console.log(store.getState()));

store.dispatch({type: ADD_ID, id: 3}); // { ids: [ 3 ] }
store.dispatch({type: ADD_ID, id: 4}); // { ids: [ 3, 4 ] }

store.replaceReducer(combineReducers({users})); // { users: [] }

store.dispatch({type: ADD_USER, user: 'Alice'}); // { users: [ 'Alice' ] }

但し、同じキーに対して異なるreducerを設定すれば当然、先程と同様の問題が発生する。

const store = createStore(combineReducers({ids}));

store.subscribe(() => console.log(store.getState()));

store.dispatch({type: ADD_ID, id: 3}); // { ids: [ 3 ] }
store.dispatch({type: ADD_ID, id: 4}); // { ids: [ 3, 4 ] }

// 既存の state.ids がそのまま維持される
store.replaceReducer(combineReducers({ids: users})); // { ids: [ 3, 4 ] }

store.dispatch({type: ADD_USER, user: 'Alice'}); // { ids: [ 3, 4, 'Alice' ] }

この例だと、replaceReducerの際に、state.idsがそのまま維持される。
そのため、ADD_USERdispatchしてusersが呼び出されると、引数stateには[3, 4]が渡される。そのためそこにAliceが追加されることになる。

ホットリロードの導入

replaceReducerの実践例として、ホットリロードを導入してみる。
今回は開発用サーバとしてwebpack-dev-serverを使用する。

まず、簡単な React + Redux アプリを作る。

まずはreducer

// reducer.js
export const reducer = (state = 0, action) => {
  const {type, amount} = action;
  switch (type) {
    case 'INCREMENT':
      return state + amount;
    case 'DECREMENT':
      return state - amount;
    default:
      return state;
  }
};

次にコンポーネント。

// App.js
import React from 'react';
import {useSelector, useDispatch} from 'react-redux';

export const App = () => {
  const state = useSelector(s => s);
  const dispatch = useDispatch();

  const increment = () => {
    dispatch({type: 'INCREMENT', amount: 1});
  };

  const decrement = () => {
    dispatch({type: 'DECREMENT', amount: 1});
  };

  return (
    <div>
      {state}
      <br />
      <button type="button" onClick={increment}>
        count up
      </button>
      <button type="button" onClick={decrement}>
        count down
      </button>
    </div>
  );
};

最後に、エントリポイントであるindex.jsで、storeとコンポーネントを結びつける。

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import {createStore} from 'redux';
import {Provider} from 'react-redux';

import {App} from './App';
import {reducer} from './reducer';

const store = createStore(reducer);

const renderApp = () => {
  ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    document.querySelector('#app')
  );
};

renderApp();

この状態でアプリを起動すると、問題なく動作する。ボタンを押下することで、表示されている数字を増減できる。

この時点ではホットリロードは有効になっていない。
そのため、例えばreducerを編集して保存するとブラウザが自動的にリロードされ、state0にリセットされてしまう。
ホットリロードを有効にすることで、自動リロードを有効にしたまま、stateが維持されるようになる。

まず、webpack.config.jsを編集し、devServer.hotを有効にする。

// webpack.config.js
  devServer: {
    // 省略
    hot: true,
  },

次に、index.jsに次のコードを追加する。

if (process.env.NODE_ENV !== 'production' && module.hot) {
  module.hot.accept('./reducer', () => store.replaceReducer(reducer));
}

これで完了。
この状態で再びアプリを起動させると、reducerを編集してリロードが発生しても、stateの値は維持されている。

./reducerを監視し、これが変更されたときに、replaceReducerを使ってreducerを変更済みのものに置き換えている。

なお、コードを以下のように書き換えると、./Appも監視対象となり、変更が発生するとrenderAppを実行してマウントし直すようになる。
もちろんこれもホットリロードなので、コンポーネントが更新されてもstateの値は維持される。

if (process.env.NODE_ENV !== 'production' && module.hot) {
  module.hot.accept('./App', renderApp);
  module.hot.accept('./reducer', () => store.replaceReducer(reducer));
}

参考資料