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)); }