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 を都度生成する、みたいな発想の転換が必要な気がする。もうちょっと研究が必要。