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というreducerをcreateStoreに渡して、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.typeがMULTIPLICATIONのときは乗算を行い、それ以外のときは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)が引き継がれる。
そして、reducerがmultiplicationに変わっているため、typeがINCREMENTのactionを渡しても、stateは変化しない。
state はそのまま維持される
既に見たように、replaceReducerを実行してもstateはそのまま維持される。これを理解せずに実装を行うと、不具合を生んでしまう可能性がある。
先程の例ではplusMinusとmultiplication、どちらのreducerも同じ形状のstateを想定していたため、問題にならなかった。
つまりstate: numberである。
異なる形状のstateを想定しているreducerで入れ替えてしまうと、多くの場合問題が発生する。
例として、idsとusersという 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; } };
idsはstate: {ids: number[]}を想定し、usersはstate: {users: string[]}を想定している。
最初にidsをreducerとして設定し、そのあとで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を設定することになるので、この問題は発生しない。
各reducerはstate全体ではなく自身が担当するstateのみを受け取るので、それに合わせてidsとusersも書き直している。
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_USERをdispatchして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を編集して保存するとブラウザが自動的にリロードされ、stateは0にリセットされてしまう。
ホットリロードを有効にすることで、自動リロードを有効にしたまま、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)); }