LoginSignup
3
3

More than 1 year has passed since last update.

redux と react-redux の内部動作を解説 ~小さな “Reduxもどき” を自作してみる~

Last updated at Posted at 2021-05-12

はじめに

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もどき” を作成して動かしてみる」以降

react-redux の Provier が store(createStore の戻り値)に求めているプロパティ【必須3メソッド】

Provider.js の PropTypes を見てみると、store インスタンスのプロパティとして subscribedispatchgetState (以下、「必須3メソッド」と呼称) が必要であることが分かります。「必須3メソッド」についての概要は後述します。

Provider.js(一部抜粋)
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 と言った時には、AB は任意の型ですが、redux では BcurrentStateA は Action です。
currentState が reducer と Action によって initialState -> State1 -> State2 と変遷する様子を図示します。

image.png
2

必須3メソッド(getState、subscribe、dispatch)の概要

※ 分かりやすさ優先のため、説明で用いる変数名は実際の変数名とは異なる場合があります。すべてのパターンを網羅した完全な説明ではなく、あくまでも概要です。

getState

store の内部変数 currentState を取得します。

Redux Toolkit Quick Start においては、currentState{ counter: { value: number } } です。

subscribe

store の 内部変数3listeners4notify関数(後述)を“登録”(※)します。
最初に実行される useSelector hook にて実行されます。5

※ redux の listeners は Array なので、“登録”というのは、listeners.push(notify関数) を意味します。

なお、戻り値は subscribe で登録した notify関数 を listenersから 除去する unsubscribe関数 です。

notify関数 について

redux には Observer パターン が用いられていますが、redux-react にも Observer パターン が用いられていて6、redux-react は内部に lisners をもっています。 redux-react 内の lisners には useSelector hook にて checkForUpdates関数が登録されます5checkForUpdates関数については後述)。react-redux 内の lisnerscheckForUpdates関数の配列7) を全て実行する関数が notify関数 です。

dispatch

useDispatch hook で取得する、dispatch です。
大きく分けて以下の2つのことを行います。
1. currentState = reducer(currentState, action) 4
2. store 内部変数 lisners4 に登録された全ての関数を実行(※)
(3. 引数の action をそのまま return)

※ redux(store)内のlisteners には、基本的には notify関数 の1つしか登録されないため、実際に実行される関数は notify関数 だけです。(ただし、react-redux 以外のコードが subscribe を使用している場合はこの限りではありません。)

(useSelector 内の)checkForUpdates 関数 の概要

  1. store.getState()currentState を取得 (store はレンダー時に Provider(Contex)から取得)
  2. useSelector 第一引数の selector を用いて currentState から 対象データを取得し、latestSelectedState.current に保存
  3. 前回保存対象データ(※1)と 最新対象データ(2.) を比較 (デフォルト:Strict equality (===) で比較 / 第二引数 equalityFn が渡されていれば、それを利用して比較)
  4. (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 の数だけ5、「selectorによるデータ取得及びデータの比較」 が実行されます。

“Reduxもどき” を作成して動かしてみる

「Redux の基本的な動作(react-redux と関係する部分のみ解説)」を踏まえて Redux を模倣した 簡単なソースコードを書いてみます。

State (Store内部) の更新方針

“Reduxもどき” では、簡略化のため、(prevState: S) => S という形の関数(以下、modifier と呼称)で store の内部変数 currentState を更新します。
currentState を変更させる関数として、
(B, A) => B8 (reducer)
ではなく、
(A) => (B) => B 9 (modifierCreator)
という形の関数を使います。
A は任意の型であり、Action({type: "name", payload: something}のようなオブジェクト)である必要はありません。
BcurrentState です。
以下は modifier の例です。

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 と変遷する様子を図示します。
image.png
10

必須3メソッド(getState、subscribe、dispatch)の動作方針

getState

redux と同じです。(store の内部変数3 currentState を取得します。)

subscribe

notify関数 をlisteners配列 に push するのではなく、listener変数 に代入します。
listeners に登録されるものが基本的には notify関数 だけであることを示すためです。

戻り値は notify関数 を listener変数 から 破棄する unsubscribe関数 です。

dispatch

引数として Action ではなく、modifier ((B)=>Bの関数)を渡します。
大きく分けて2つのことを行います。
1. currentState = modifier(currentState)
2. 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 の自作)

変更前のソース(Github)

src/app/store.js
import { configureStore } from '@reduxjs/toolkit'; // redux の `createStore` を内部で利用
import counterReducer from '../features/counter/counterSlice';

export default configureStore({
  reducer: {
    counter: counterReducer,
  },
});

変更後のソース(Github)

src/app/store.js
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 の変更

変更前のソース(Github)

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;

変更後のソース(Github)

src/features/counter/counterSlice.js
// 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 が返す storedispatch は redux-thunk を含む middleware が合成された dispatch です(※)。今回実装した dispatch にはそのような合成を行っていないので、redux-thunk の合成なしで動くような書き方に変更します。

src/features/counter/Counter.js
-  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 をスッキリ記述することができます。

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 の 関数は全てカリー化されていますが、通常の関数と同様に(カリー化された関数であることを意識せずに)使えるので、カリー化を知らない人も利用できます。


  1. 公式サイト(💡TIP)では「React-Redux hooks APIをデフォルトとして使用すること」が勧められているので hooks API の理解を優先しました。 

  2. 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. 内部変数は「クロージャ内に存在するアクセスを制限された変数」という意味合いで用いています。 

  4. 実際の変数名とは異なります。ここでは理解しやすさを優先しています。 

  5. 「はじめに > 注意」でお断りを入れた通り、connect API は説明対象外にしています。 

  6. 参考: The Observer Pattern In JavaScript as Implemented By Redux 及び An Obsession with Design Patterns: Redux。react-redux が Observer パターン を使っていると明言している記事は見つかりませんでしたが、Observer パターンに近い実装であることは間違いないと思います。 

  7. 正確には linked list。 

  8. TypeScript表記: type Reducer<A, B> = (b: B, a: A) => B; 

  9. TypeScript表記: type CreateEndomorphism<A, B> = (a: A) => (b: B) => B; 

  10. mermaidを利用 graph TD
    S0[B: initialState]-->R1((modifier : B => B))
    R1 --> S1[B: State1]
    S1-->R2((modifier : B => B))
    R2 --> S2[B: State2] 

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3