📌

constate ~Contextの分離を簡単にするたった40行弱のライブラリ~

2021/05/29に公開

ReactのContextを使う場合のパフォーマンスへの配慮として、Contextを分離するアプローチがある。
(どうやらReactレコメンドらしい。ソースは微妙だがドキュメントにもcontextを分割したほうが良いよみたいなことは書かれている気がする)

https://reactjs.org/docs/context.html#consuming-multiple-contexts
https://reactjs.org/docs/context.html#when-to-use-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行もない簡単なやつだったので眺めてみた。

https://github.com/diegohaz/constate/blob/48ac889ec42ef84ee088161e0393ebd904fa1363/src/index.tsx

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を指定するだけでやってくれるだけの軽量なライブラリなので、
個人的には選択肢としてありだなと思った。(プロダクトの寿命がながいやつだと、流行りの状態管理ライブラリは入れられない)

ソースコードは型のお勉強するの良いかもしれない。

参考 https://onderonur.netlify.app/blog/state-management-and-performance-optimizations-with-react-context-api-and-hooks/

Discussion