constate ~Contextの分離を簡単にするたった40行弱のライブラリ~
ReactのContextを使う場合のパフォーマンスへの配慮として、Contextを分離するアプローチがある。
(どうやらReactレコメンドらしい。ソースは微妙だがドキュメントにもcontextを分割したほうが良いよみたいなことは書かれている気がする)
そして、contextの分離を簡単にやってくれるconstateというライブラリがある。
引数にselectorと呼ばれる関数を定義すると、それに応じてcontextを分離してくれる。
(READMEより)
import React, { useState, useCallback } from "react";
import constate from "constate";
// 1️⃣ Create a custom hook that receives props
function useCounter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
// 2️⃣ Wrap your updaters with useCallback or use dispatch from useReducer
const increment = useCallback(() => setCount(prev => prev + 1), []);
return { count, increment };
}
// 3️⃣ Wrap your hook with the constate factory splitting the values
const [CounterProvider, useCount, useIncrement] = constate(
useCounter,
value => value.count, // becomes useCount
value => value.increment // becomes useIncrement
);
function Button() {
// 4️⃣ Use the updater context that will never trigger a re-render
const increment = useIncrement();
return <button onClick={increment}>+</button>;
}
function Count() {
// 5️⃣ Use the state context in other components
const count = useCount();
return <span>{count}</span>;
}
const [CounterProvider, useCount, useIncrement] = constate(
useCounter,
value => value.count, // becomes useCount
value => value.increment // becomes useIncrement
);
constateの第2引数以降に分割したい単位でSelectorを書くことでContextが分離される。
上の例だと、Context.Providerが2つ描画される。
ソースコードが100行もない簡単なやつだったので眺めてみた。
const isDev = process.env.NODE_ENV !== "production";
const NO_PROVIDER = {};
function createUseContext(context: React.Context<any>): any {
return () => {
const value = React.useContext(context);
if (isDev && value === NO_PROVIDER) {
// eslint-disable-next-line no-console
console.warn("Component must be wrapped with Provider.");
}
return value;
};
}
function constate<Props, Value, Selectors extends Selector<Value>[]>(
useValue: (props: Props) => Value,
...selectors: Selectors
): ConstateTuple<Props, Value, Selectors> {
const contexts = [] as React.Context<any>[];
const hooks = ([] as unknown) as Hooks<Value, Selectors>;
const createContext = (displayName: string) => {
const context = React.createContext(NO_PROVIDER);
if (isDev && displayName) {
context.displayName = displayName;
}
contexts.push(context);
hooks.push(createUseContext(context));
};
if (selectors.length) {
selectors.forEach((selector) => createContext(selector.name));
} else {
createContext(useValue.name);
}
const Provider: React.FC<Props> = ({ children, ...props }) => {
const value = useValue(props as Props);
let element = children as React.ReactElement;
for (let i = 0; i < contexts.length; i += 1) {
const context = contexts[i];
const selector = selectors[i] || ((v) => v);
element = (
<context.Provider value={selector(value)}>{element}</context.Provider>
);
}
return element;
};
if (isDev && useValue.name) {
Provider.displayName = "Constate";
}
return [Provider, ...hooks];
}
export default constate;
selectorに対応する分だけcontextでネストをしたものを作るだけだ。
contextとhook(useContext(context)の戻り値)をそれぞれ配列として保持している。
for文で再帰的に親子関係を作っていく。
selector(value)がcontextのvalueに入っていく。
(これは使う側でやっている)
ついでにTypeScriptの型定義も読んでみる。
import * as React from "react";
// constate(useCounter, value => value.count)
// ^^^^^^^^^^^^^^^^^^^^
type Selector<Value> = (value: Value) => any;
これは普通のSelector Value型を引数に持つ関数だ。
// const [Provider, useCount, useIncrement] = constate(...)
// ^^^^^^^^^^^^^^^^^^^^^^
type SelectorHooks<Selectors> = {
[K in keyof Selectors]: () => Selectors[K] extends (...args: any) => infer R
? R
: never;
};
(Mapped Typeやinferなど、TypeScriptを知らないと読めないなぁ。)
実際にはSelectorsにはタプルが渡ってくる。
やってることは、
[(Value) => string, (Value) => () => void]
といったタプルをもとに、戻り値を返すタプルを返す。
この例だと
[() => string, () =>() => void]
(結果だけ見れば引数のValueをvoidに変換すると捉えることもできるが)
サンプルコードのuseIncrement, (value) => value.increment
セレクターに対応する。
// const [Provider, useCounterContext] = constate(...)
// or ^^^^^^^^^^^^^^^^^
// const [Provider, useCount, useIncrement] = constate(...)
// ^^^^^^^^^^^^^^^^^^^^^^
type Hooks<
Value,
Selectors extends Selector<Value>[]
> = Selectors["length"] extends 0 ? [() => Value] : SelectorHooks<Selectors>;
Selectors["length"] extends 0 つまりSelectorが空配列と解釈される場合とそれ以外で分けている。Selectorを指定しない場合の型定義ですね。
// const [Provider, useContextValue] = constate(useValue)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^
type ConstateTuple<Props, Value, Selectors extends Selector<Value>[]> = [
React.FC<Props>,
...Hooks<Value, Selectors>
];
これがconstateの最終的な戻り値
ProviderというReact.FCを返す && のこりは ...Hooks<Value, Selectors>
...HooksはTypeScript4.0から使えるのようになったVariadic Tuple Types
これで数に応じた型定義ができるのか。
React.Contextの分離をselectorを指定するだけでやってくれるだけの軽量なライブラリなので、
個人的には選択肢としてありだなと思った。(プロダクトの寿命がながいやつだと、流行りの状態管理ライブラリは入れられない)
ソースコードは型のお勉強するの良いかもしれない。
Discussion