react-redux と useContext を組み合わせて使う実験

react-redux v6 で中身が新しい方の Context API になったと聞いたので、コード読んでみたらContext自体が外部に export されていた。じゃあ hooks の useContext と組み合わせて使えるじゃん、と思って実験してみた。

useContext で connect 相当の処理を置き換えてみる。

https://github.com/reduxjs/react-redux/blob/master/src/index.js

import React, { useContext } from "react";
import ReactDOM from "react-dom";
import { createStore, Store } from "redux";
import { ReactReduxContext, Provider } from "react-redux";

type State = {
  count: number;
};

// いつもの reducer
const initialState: State = { count: 0 };

function reducer(state: State = initialState, action: any) {
  switch (action.type) {
    case "reset": {
      return initialState;
    }
    case "increment": {
      return { count: state.count + 1 };
    }
    case "decrement": {
      return { count: state.count - 1 };
    }
    default: {
      return state;
    }
  }
}

function Counter() {
  // useContext で store を取り出す
  const {
    storeState,
    store: { dispatch }
  } = useContext(ReactReduxContext);
  const counterValue = storeState.count;
  return (
    <div>
      Count: {counterValue}
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </div>
  );
}

function App(props: { store: Store<State> }) {
  return (
    <Provider store={props.store}>
      <Counter />
    </Provider>
  );
}

const store: Store<State> = createStore(reducer);
ReactDOM.render(<App store={store} />, document.querySelector(".root"));

これで動いた。が、いくつか問題がある。

react-redux の connect は map された値の変更を監視していて、HOCが自分に関係ない値の更新処理を抑制してくれていた。だが、この素朴に useContext している版は、 store のすべての値に反応してしまう。

useContext した時点ですべての更新を listen してしまうので、結局これ自身が子への抑制処理を行う必要がある。

色々頑張った結果、こうなった

import React, { useContext, useCallback } from "react";
import ReactDOM from "react-dom";
import { createStore, Store, Dispatch, AnyAction } from "redux";
import { ReactReduxContext, Provider } from "react-redux";

// helper
function useDispatch(): Dispatch<AnyAction> {
  const {
    store: { dispatch }
  } = useContext(ReactReduxContext);
  return dispatch;
}

function useConnect<T, U>(fn: (t: T) => U): U {
  const { storeState } = useContext(ReactReduxContext);
  return fn(storeState);
}

type State = {
  count: number;
  resetCount: number;
};

const initialState: State = { count: 0, resetCount: 0 };

function reducer(state: State = initialState, action: AnyAction) {
  switch (action.type) {
    case "reset": {
      return { ...initialState, resetCount: state.resetCount + 1 };
    }
    case "increment": {
      return { ...state, count: state.count + 1 };
    }
    case "decrement": {
      return { ...state, count: state.count - 1 };
    }
    default: {
      return state;
    }
  }
}

const CounterInner = React.memo(function CounterInner(props: {
  counterValue: number;
  onClickReset: () => void;
  onClickIncrement: () => void;
  onClickDecrement: () => void;
}) {
  console.log("update counter inner");
  return (
    <div>
      Count: {props.counterValue}
      <button onClick={props.onClickReset}>Reset</button>
      <button onClick={props.onClickIncrement}>+</button>
      <button onClick={props.onClickDecrement}>-</button>
    </div>
  );
});

function Counter() {
  console.log("update counter", Date.now());

  const dispatch = useDispatch();
  const counterValue = useConnect<State, number>(state => state.count);
  const onClickReset = useCallback(() => dispatch({ type: "reset" }), []);
  const onClickIncrement = useCallback(
    () => dispatch({ type: "increment" }),
    []
  );
  const onClickDecrement = useCallback(
    () => dispatch({ type: "decrement" }),
    []
  );
  return (
    <CounterInner
      counterValue={counterValue}
      onClickReset={onClickReset}
      onClickIncrement={onClickIncrement}
      onClickDecrement={onClickDecrement}
    />
  );
}

function App(props: { store: Store<State> }) {
  return (
    <Provider store={props.store}>
      <Counter />
    </Provider>
  );
}

const store: Store<State> = createStore(reducer as any);
ReactDOM.render(<App store={store} />, document.querySelector(".root"));

Counter は自分自身で element の詳細を生成せず、CounterInner に props を渡す。その際の関数は、useCallback を使ってメモ化する。

CounterInner 側では React.memo を使って shallow equal の比較によって更新処理を抑制している。CounterInner は count に興味があるが、 resetCount には興味がない。reset を連打した際は CounterInner の更新が行われないようにすることができた。

感想

やってみたけど、メモ化を駆使して自力でuseContextでとった値の更新をコントロールするのは結構だるい。useContext に mapState 相当の処理がほしい。

たぶん react-redux の大きなグローバル変数を持つみたいな考え方じゃなくて、connect される Context を都度生成する、みたいな発想の転換が必要な気がする。もうちょっと研究が必要。