はじめに
Redux初心者向けの記事です。自分の実装体験談を基にご紹介します。
中〜上級者もしくはReduxの公式ドキュメントを熟読している方ならば、タイトルを見ただけで大体どんな事かと想像が付くと思いますので、この記事は読まなくても良いかもしれません…が、一応、問題に陥らないための対処法もご紹介しますので、これから新たに初心者がReactプロジェクトに入ってくることが想定されるならば、対処法の部分だけでも読んでいただけると参考になるかもしれません。
TL;DR
- ReduxのStateを変更したにも関わらず、再レンダリングされない(問題)
- Stateが変更されたにも関わらず、なぜ再レンダリングされないか(原因)
- 不正にStateが変更される場合、コンソールにエラーを吐いてくれるようにする(問題回避策)
ReduxのStateを変更
Reducer
Reducerは、typescript-fsa-reducersのreducerWithInitialState
を使って、次の様な感じで定義していました。
export const xxxReducer = reducerWithInitialState(xxxInitialState)
.case(setXxxIds, (state: IXxxState, payload) => ({
...state,
xxxIds: payload,
}))
.build();
reducerWithInitialState
についての説明は本記事の範疇から外れてしまいますので、詳細については割愛しますが、FSA(flux-standard-action)に則り、setXxxIds
(Action Creator関数)によって生成されたActionオブジェクトのうち、payloadとして渡ってくるものがxxxIds
のStateにセットされます。
redux-saga
非同期処理をredux-sagaを使って実装し、Ruducerに渡したいActionをdispatchしています。
function* setXxxIdSaga(payload: IXxxPayload): SagaIterator {
// StoreからxxxIdsのStateを取り出し、xxxIds(配列)に代入
const xxxIds = yield select(
(state: IXxxStore) => state.xxx.xxxIds,
);
// xxxIdsの配列に新要素(xxxId)を追加
xxxIds.push(payload.xxxId);
// xxxIdsをActionオブジェクトのpayloadに割り当てられるようにdispatch
yield put(setXxxIds(xxxIds));
}
このSagaタスク内では最終的に、Action Creator関数のsetXxxIds
を使ってxxxIds
を引数に取り、xxxIds
がActionオブジェクトのpayloadに割り当てられるようにActionをdispatchしています。
この時点で、Reduxの実装でやってはいけないことをやっていることに気付く方も多いと思いますが、この実装でも一応、下記の通りStateの変更は確認できました。
変更前
変更後(Sagaタスク内で指定した要素が追加されている)
Stateが変更されたのに、ContainerがラップするPresentational Componentの再レンダリングされない!と、見事にハマってしまいました…。
なぜ再レンダリングが行われないか
コンポーネントの再レンダリング実行の必要性判断に、Reduxは浅い比較(shallow equality checking
)を採用して、Stateの変更をチェックしています。
React-Redux uses shallow equality checking to determine whether the component it’s wrapping needs to be re-rendered.
How does React-Redux use shallow equality checking?
上記の例では、Array.push
メソッドを使って、Stateにセットされている値(xxxIds
)自体に変更を加えたものを、Reducerを通じてセットしていました。
xxxIds
の配列の要素に変更が加わっても(中身が変わっても)、 xxxIds
の参照(メモリ上の番地)は変わっていません 。
浅い比較では、オブジェクトの参照のみをチェックしているため、オブジェクトの中身が変わったとしても参照が変わらなければ、変更を検知することができないのです。
深い比較deep equality checking (or value equality)
よりも、浅い比較Shallow equality checking (or reference equality)
が採用されている理由については、パフォーマンスを良くするためです。
Shallow equality checking (or reference equality) simply checks that two different variables reference the same object; in contrast, deep equality checking (or value equality) must check every value of two objects' properties.
A shallow equality check is therefore as simple (and as fast) as a === b, whereas a deep equality check involves a recursive traversal through the properties of two objects, comparing the value of each property at each step.
It's for this improvement in performance that Redux uses shallow equality checking.
Why does Redux’s use of shallow equality checking require immutability?
上記の例への対策として、Sagaタスク内の実装でArray.push()
を使うのをやめて
xxxIds.push(payload.xxxId);
を
const newXxxIds = xxxIds.concat(
payload.xxxId,
);
と変更し、newXxxIds
に対して、xxxId
を追加済みの配列を新しく代入すれば、参照が変わり、再レンダリングが行われる様になります。
不正にStateが変更される場合への対処
そもそも、各Stateに割り当てられた値で、オブジェクトや配列の中身だけが変えられるような不正な変更がされないよう、気を付けて実装していけば良いのですが、Redux公式のドキュメントを熟読していない初心者や、中級者以上でもついうっかり…という可能性も有るかと思います。
そのような不正なStateの変更がされた場合において、次の様に、コンソールにエラーを吐いてくれるライブラリが存在します。
redux-immutable-state-invariantの導入
redux-immutable-state-invariant
Redux middleware that detects mutations between and outside redux dispatches. For development use only.
導入方法については、ライブラリの公式サイトに記載あるように
npm install --save-dev redux-immutable-state-invariant
または
yarn add -D redux-immutable-state-invariant
で、まずはdevDependenciesとしてインストールします。
続けて、Storeを生成する箇所でライブラリを適用しますが、Redux-Sagaとredux-devtoolsを使用している自分の実装では、次の様な形で適用することができました。
import { applyMiddleware, compose, createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { rootCourseReducer } from 'reducers';
import { sagaMiddleware } from 'sagas';
import { isProd } from 'env';
const composeMiddleware = () => {
const reduxImmutableStateInvariant = require('redux-immutable-state-invariant').default();
const middleware =
process.env.NODE_ENV !== 'production'
? [sagaMiddleware, reduxImmutableStateInvariant]
: [sagaMiddleware];
const composed = compose(applyMiddleware(...middleware));
const composeWithReduxDevTool = composeWithDevTools({});
return isProd() ? composed : composeWithReduxDevTool(composed);
};
const buildCourseStore = () =>
createStore(reducer, {}, composeMiddleware());
const store = buildCourseStore();