createContext
と useReducer
を組み合わせて簡易的な Redux を再現する方法を記します。
コード
import React from "react";
type StoreProviderProps<Store> = {
initialState?: Store;
};
export function createStore<Store, Action>(
initialState: Store,
reducer: React.Reducer<Store, Action>
) {
const storeContext = React.createContext(initialState);
const dispatchContext = React.createContext<React.Dispatch<Action>>(() => {});
const StoreProvider: React.FC<StoreProviderProps<Store>> = (props) => {
const [state, dispatch] = React.useReducer(
reducer,
props.initialState ?? initialState
);
return (
<storeContext.Provider value={state}>
<dispatchContext.Provider value={dispatch}>
{props.children}
</dispatchContext.Provider>
</storeContext.Provider>
);
};
function useSelector(): Store;
function useSelector<T>(selector: (state: Store) => T): T;
function useSelector<T>(selector?: (state: Store) => T) {
const state = React.useContext(storeContext);
return selector !== undefined ? selector(state) : state;
}
function useDispatch() {
return React.useContext(dispatchContext);
}
return [StoreProvider, useSelector, useDispatch] as const;
}
使い方
import { createStore } from "./createStore";
type State = { count: number };
type Action =
| { type: "increment" }
| { type: "incrementByAmount"; payload: number };
const initialState: State = { count: 2 };
const reducer: React.Reducer<State, Action> = (state, action) => {
switch (action.type) {
case "increment": {
return { ...state, count: state.count + 1 };
}
case "incrementByAmount": {
return { ...state, count: state.count + action.payload };
}
}
};
export const [StoreProvider, useSelector, useDispatch] = createStore(initialState, reducer);
createStore
関数に初期ステートとreducer
関数を渡します。
reducer
関数は直前のステートとアクションを引数で受け取り新ステートを返すよう作ります。
この関数の戻り値は、順番にStoreProvider
, useSelector
, useDispatch
となっています。(固定長タプルなので任意の名前をつけることができます。)
StoreProvider
function App() {
return (
<div>
<Count />
<Incrementer />
</div>
);
}
ReactDOM.render(
<StoreProvider>
<App />
</StoreProvider>,
document.getElementById("root")
);
ステートを共有したい範囲の最上位コンポーネントを StoreProvider
で括ります。
上記の例ではトップレベルに配置していますが、そこである必要はなく、もっと範囲を絞ることもできます。
useSelector
const Count = () => {
const count = useSelector((state) => state.count);
return <p>count: {count}</p>;
};
StoreProvider
の内側のコンポーネントで useSelector
フックを使用して現在のステートを取得することができます。
Reduxの useSelctor
を意識して (state) => state.count
のようなステートを変換する関数を受け取れるようにしてありますが、Reduxのようにそのコンポーネントで使用していないステートの変化による re-render を抑制する効果はありません。(あくまでインターフェイスの簡易的な再現)
useDispatch
const Incrementer = () => {
console.log("rendered Incrementer.");
const [amount, setAmount] = React.useState("1");
const dispatch = useDispatch();
return (
<>
<button onClick={() => dispatch({ type: "increment" })}>increment</button>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={() =>
dispatch({
type: "incrementByAmount",
payload: Number(amount) || 0,
})
}
>
increment by amount
</button>
</>
);
};
StoreProvider
の内側のコンポーネントで useDispatch
フックを使用して dispatch
関数を取得することができます。
クリックイベントなどで dispatch
関数に reducer
関数が要求している形式の action
オブジェクトを渡してあげることでステートを更新することができます。
TypeScriptを使用していれば、 Action
のタイプなりインターフェイスを定義して使用されるので、決められた形以外の action
オブジェクトを useDispatch
に渡そうとするとコンパイルエラーになります。また、エディターの補完も効くのでTypeScriptのほうが開発者体験がいいはずです。
説明
やっていることは useReducer
のステートと dispatch
をコンテキストで配信しているだけです。
useContext
をラップしたフックを作ることでreduxのAPIを真似ています。
ただし useContext
を使用するコンポーネントは、当該コンテキストの変更をすべて検知して re-render されてしまうので、更新頻度の高いステートを大きなコンポーネントツリーで共有するとパフォーマンスに影響します。
せめてものパフォ低下対策として、ステートと dispatch
関数を配信するコンテキストを分けることで、useDispatch
だけを使用しているコンポーネントがステート更新によって re-render されないようにはなっています。
終わりに
わ た し は r e d u x を 使 っ て ア プ リ を 作 っ た こ と が な い 。
参考
https://ja.reactjs.org/docs/hooks-reference.html#usereducer
https://ja.reactjs.org/docs/hooks-reference.html#usecontext
https://mizchi.dev/202005271609-react-app-context