はじめに
react-redux と redux と @reduxjs/toolkit のソースコードの主要部分を読んだので、redux と react-redux の内部動作について自分なりに解説してみようと思います。
また、redux を使わなくても少しコードを追加するだけで react-redux を動かせると思ったので試してみました。
注意・前提
本稿は hooks API を前提にしており、connect API のことは考慮しておりません。1
前提とするバージョンは以下の通りです。
- react-redux: 7.2.3
- redux: 4.0.4
対象読者
「Redux の基本的な動作(react-redux と関係する部分のみ解説)」まで
- redux と react-redux(hooks API) は使ったことがあるけど、redux と react-redux の内部的な動作は分からないという方
「“Reduxもどき” を作成して動かしてみる」以降
- Redux Toolkit Quick Start のソースコードを読んだことのある方
[^linked list]: react-redux では Array ではなく、linked list が使われています
react-redux の Provier が store(createStore の戻り値)に求めているプロパティ【必須3メソッド】
Provider.js の PropTypes を見てみると、store
インスタンスのプロパティとして subscribe
、dispatch
、getState
(以下、「必須3メソッド」と呼称) が必要であることが分かります。「必須3メソッド」についての概要は後述します。
if (process.env.NODE_ENV !== 'production') {
Provider.propTypes = {
store: PropTypes.shape({
subscribe: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
getState: PropTypes.func.isRequired,
}),
context: PropTypes.object,
children: PropTypes.any,
}
}
Redux の基本的な動作(react-redux と関係する部分のみ解説)
State (Store内部) の変化の様子
redux では reducer((B, A) => B) を使って store 内で保持している state (以下 currentState
と記載)を更新します。
単に reducer と言った時には、A
、B
は任意の型ですが、redux では B
は currentState
、A
は Action です。
currentState
が reducer と Action によって initialState -> State1 -> State2 と変遷する様子を図示します。
[^mermaid 1]
[^mermaid 1]: mermaidを利用 graph TD
S0[B: initialState]-->R1((reducer: B, A => B))
A1[A: Action1] -- type:x1 payload: y1 -->R1
R1 --> S1[B: State1]
S1-->R2((reducer: B, A => B))
A2[A: Action2] -- type:x2 payload: y2 -->R2
R2 --> S2[B: State2]
必須3メソッド(getState、subscribe、dispatch)の概要
※ 分かりやすさ優先のため、説明で用いる変数名は実際の変数名とは異なる場合があります。すべてのパターンを網羅した完全な説明ではなく、あくまでも概要です。
getState
store の内部変数 currentState
を取得します。
Redux Toolkit Quick Start においては、currentState
は { counter: { value: number } }
です。
subscribe
store
の 内部変数2listeners
3 に notify
関数(後述)を“登録”(※)します。
最初に実行される useSelector
hook にて実行されます。4
※ redux の listeners
は Array なので、“登録”というのは、listeners.push(notify関数)
を意味します。
なお、戻り値は subscribe
で登録した notify
関数 を listeners
から 除去する unsubscribe
関数 です。
notify
関数 について
redux には Observer パターン が用いられていますが、redux-react にも Observer パターン が用いられていて[^Observer パターン]、redux-react は内部に lisners
をもっています。 redux-react 内の lisners
には useSelector
hook にて checkForUpdates
関数が登録されます4 (checkForUpdates
関数については後述)。react-redux 内の lisners
(checkForUpdates
関数の配列[^正確には linked list]) を全て実行する関数が notify
関数 です。
[^正確には linked list]: 正確には linked list。
dispatch
useDispatch
hook で取得する、dispatch
です。
大きく分けて以下の2つのことを行います。
-
currentState = reducer(currentState, action)
3 - store 内部変数
lisners
3 に登録された全ての関数を実行(※)
(3. 引数の action をそのまま return)
※ redux(store)内のlisteners
には、基本的には notify
関数 の1つしか登録されないため、実際に実行される関数は notify
関数 だけです。(ただし、react-redux 以外のコードが subscribe
を使用している場合はこの限りではありません。)
(useSelector 内の)checkForUpdates 関数 の概要
-
store.getState()
でcurrentState
を取得 (store
はレンダー時に Provider(Contex)から取得) -
useSelector
第一引数のselector
を用いてcurrentState
から 対象データを取得し、latestSelectedState.current
に保存 - 前回保存対象データ(※1)と 最新対象データ(2.) を比較 (デフォルト:Strict equality (===) で比較 / 第二引数 equalityFn が渡されていれば、それを利用して比較)
- (3.) の比較結果が false なら(対象データが前回と異なるデータなら)
forceRender
(※2)で強制的にコンポーネントを再レンダーする
※1 前回実行時、(2.)にて latestSelectedState.current
に保存したデータ
※2 forceRender: const [, forceRender] = useReducer((s) => s + 1, 0)
必須3メソッドまとめ
redux (store) | react-redux | |
---|---|---|
lisners に登録される関数 | notify(react-redux 内の listeners(checkForUpdatesたち)を全て実行する関数) | checkForUpdates |
lisners に登録される関数の数 | 1つ(Provider につき1つ) | useSelector 等の個数 |
lisners に登録された関数が実行されるタイミング | dispatch 実行時 | dispatch 実行時 |
dispatchの実行 -> listenrs(redux)の実行 -> notify 関数の実行 -> lisners(react-redux)の実行 -> 全てのcheckForUpdatesの実行 という流れになります。 |
||
つまり、useSelector 等で登録された checkForUpdates 関数たちが dispatch で全部実行されるということになります。
|
||
dispatch を実行すると、useSelector の数だけ4、「selectorによるデータ取得及びデータの比較」 が実行されます。 |
“Reduxもどき” を作成して動かしてみる
「Redux の基本的な動作(react-redux と関係する部分のみ解説)」を踏まえて Redux を模倣した 簡単なソースコードを書いてみます。
State (Store内部) の更新方針
“Reduxもどき” では、簡略化のため、(prevState: S) => S
という形の関数(以下、modifier と呼称)で store の内部変数 currentState
を更新します。
currentState
を変更させる関数として、
(B, A) => B
5 (reducer)
ではなく、
const [count, setCount] = React.useState(0);
const add = (a) => (b) => a + b // modifierCreator: (A) => (B) => B
const inclement = add(1) // 👈 modifier: (B) => B (ここでは、(number) => number)
// modifier の使い方: setCount(inclement)
currentState
が modifier によって initialState -> State1 -> State2 と変遷する様子を図示します。
[^mermaid 2]
[^mermaid 2]: mermaidを利用 graph TD
S0[B: initialState]-->R1((modifier : B => B))
R1 --> S1[B: State1]
S1-->R2((modifier : B => B))
R2 --> S2[B: State2]
必須3メソッド(getState、subscribe、dispatch)の動作方針
getState
redux と同じです。(store の内部変数2 currentState
を取得します。)
subscribe
notify
関数 をlisteners
配列 に push するのではなく、listener
変数 に代入します。
listeners
に登録されるものが基本的には notify
関数 だけであることを示すためです。
戻り値は notify
関数 を listener
変数 から 破棄する unsubscribe
関数 です。
dispatch
引数として Action ではなく、modifier ((B)=>B
の関数)を渡します。
大きく分けて2つのことを行います。
currentState = modifier(currentState)
-
listener
変数 に代入されたnotify
関数 を実行
必須3メソッドにおける主な相違点まとめ
redux との 一番の違いは dispatch
における currentState
の変更方法が
reducer((B, A) => B) から modifier((B) => B) になっている部分です。
redux | reduxもどき | |
---|---|---|
getState | (currentState の取得) |
(相違点なし) |
subscribe |
listeners 配列 に notify 関数 を push |
listener 変数 に notify 関数 を 代入 |
dispatch | currentState = reducer(currentState, action) | currentState = modifier(currentState) |
ソースコードの変更
以上を踏まえて redux の createStore
を自作してみます。
src/app/store.js の変更(createStore の自作)
import { configureStore } from '@reduxjs/toolkit'; // redux の `createStore` を内部で利用
import counterReducer from '../features/counter/counterSlice';
export default configureStore({
reducer: {
counter: counterReducer,
},
});
import initialState from '../features/counter/counterSlice'; // counterSlice は後述
// 前述の「必須3メソッド(getState、subscribe、dispatch)の動作方針」通りに実装
const createStore = initialValue => {
let currentState = initialValue; // state を初期化
let listener; // notify関数 の代入先内部変数
const store = {
getState: () => currentState,
subscribe: notify => {
listener = notify; // notify関数 を代入
return function unsubscribe() {
listener = undefined; // notify関数 を破棄
};
},
dispatch: modifier => {
currentState = modifier(currentState); // state を更新
listener && listener(); // notify関数 を実行
},
};
return store;
};
export default createStore(initialState);
src/features/counter/counterSlice.js の変更
import { createSlice } from '@reduxjs/toolkit';
export const slice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: state => {
state.value += 1;
},
decrement: state => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = slice.actions;
export const incrementAsync = amount => dispatch => {
setTimeout(() => {
dispatch(incrementByAmount(amount));
}, 1000);
};
export const selectCount = state => state.counter.value;
export default slice.reducer;
// import { createSlice } from '@reduxjs/toolkit'; // -
import produce from 'immer'; // +
// immer は redux toolkit 内部で利用されているライブラリ
export const entier = {
// initialState: B = { counter: { value: number } } slice ではなく、state 全体
initialState: {
counter: { value: 0 },
},
// modifierCreator: A => B => B
modifierCreators: {
// A => B => B (immer なしの場合 / A は不要)
increment: _ => state => ({
...state,
counter: {
...state.counter,
value: state.counter.value + 1,
},
}),
// A => B => B (immer ありの場合 / A は不要)
decrement: _ => produce(state => {
state.counter.value -= 1;
}),
// A => B => B ( A は number )
incrementByAmount: amount => produce(state => {
state.counter.value += amount;
}),
},
};
// dispatch には action creator の戻り値ではなく、modifier creator の戻り値を渡す
// export const { increment, decrement, incrementByAmount } = slice.actions; // -
export const { increment, decrement, incrementByAmount } = entier.modifierCreators; // +
// incrementAsync 変更なし
// selectCount 変更なし
// export default slice.reducer; // -
export default entier.initialState; // +
src/features/counter/Counter.js の変更
@reduxjs/toolkit の configureStore
が返す store
の dispatch
は redux-thunk を含む middleware が合成された dispatch
です(※)。今回実装した dispatch にはそのような合成を行っていないので、redux-thunk の合成なしで動くような書き方に変更します。
- onClick={() => dispatch(incrementAsync(Number(incrementAmount) || 0))}
+ onClick={() => incrementAsync(Number(incrementAmount) || 0)(dispatch)}
※ この説明では知ってる人にしか分からないと思いますが、本稿では Async を扱うロジックの詳細な説明は割愛させていただきます。
デモ および オリジナルとの差分
以上3つのファイルの修正で redux なしで react-redux が動くようになりました。
デモはこちら(CodeSandbox)を参照ください。
オリジナル との 差分はこちら を参照ください。
おまけ: ramda ライブラリ を利用した場合
JavaScript プログラマのための関数型ライブラリである ramda を使うと
変更後の src/features/counter/counterSlice.js をスッキリ記述することができます。
import * as R from 'ramda';
const lens = R.lensPath(['counter', 'value']);
export const entier = {
// initialState: B = { counter: { value: number } }
initialState: {
counter: { value: 0 },
},
// modifierCreator: A => B => B
modifierCreators: {
increment: _ => R.over(lens, R.inc),
decrement: _ => R.over(lens, R.dec),
incrementByAmount: amount => R.over(lens, R.add(amount)),
},
};
export const { increment, decrement, incrementByAmount } = entier.modifierCreators;
// incrementAsync 変更なし
export const selectCount = R.view(lens);
export default entier.initialState;
変更後ソースコード(immer 利用バージョン)と 変更後ソースコード(ramda 利用バージョン)との差分はこちら を参照ください。
デモはこちら(CodeSandbox)を参照ください。
ramda のススメ
ramda のドキュメント は検索性に優れ、全ての関数に説明と例が記載されており、しかも「Run it here」ですぐに動作を試せるという至れり尽くせりなドキュメントです。
ramda の全ての関数は破壊的な変更を行わないので、React の state の更新にもってこいです。
例えば evolve を使えば 下記の increment1
のような書き方をせずに react の state を更新できます。
import * as R from 'ramda';
const increment1 = state => ({
...state,
counter: {
...state.counter,
value: state.counter.value + 1,
},
});
const increment2 = state =>
R.evolve({ counter: { value: value => value + 1 } }, state); // 通常利用ver
const increment3 = R.evolve({ counter: { value: value => value + 1 } }); // カリー化利用ver
const increment4 = R.evolve({ counter: { value: R.inc } }); // ramda フル活用ver
// console.log(incrementX({ counter: { value: 0 } }) -> { counter: { value: 1 }}
ramda の 関数は全てカリー化されていますが、通常の関数と同様に(カリー化された関数であることを意識せずに)使えるので、カリー化を知らない人も利用できます。
[^Observer パターン]: 参考: The Observer Pattern In JavaScript as Implemented By Redux 及び An Obsession with Design Patterns: Redux。react-redux が Observer パターン を使っていると明言している記事は見つかりませんでしたが、Observer パターンに近い実装であることは間違いないと思います。
-
公式サイト(💡TIP)では「React-Redux hooks APIをデフォルトとして使用すること」が勧められているので hooks API の理解を優先しました。 ↩
-
TypeScript表記:
type Reducer<A, B> = (b: B, a: A) => B;
(A) => (B) => B
6 (modifierCreator) ↩ -
TypeScript表記:
type CreateEndomorphism<A, B> = (a: A) => (b: B) => B;
という形の関数を使います。
A
は任意の型であり、Action({type: "name", payload: something}
のようなオブジェクト)である必要はありません。
B
はcurrentState
です。
以下は modifier の例です。 ↩