👻

[React]ContextAPIのアンチパターン2

2022/01/19に公開約5,200字

前回

https://zenn.dev/sora_kumo/articles/72fae8a8244adf

ContextAPI のアンチパターン

ContextAPI を利用する場合、滝修行のごとくツリー上に再レンダリングするアンチパターンが何故起こるかという部分を考えてみます。

React の再レンダリングのほとんどは、useState で作られたディスパッチャーでステートを更新すると発生します。そして無駄な再レンダリングが発生するのは、そのステートが制御し切れていないからです。つまり ContextAPI よりも useState の使い方が悪いことに原因があります。

ContextAPI 自体は非常に便利で強力な機能です。しかし制御が不完全な状態で扱えば不要な再レンダリングが発生します。ここで誤解が生まれるのですが、これは ContextAPI が悪いのではなく、ただ単に使い方が悪いだけなのです。

必要なコンポーネントにのみ再レンダリングするように ContextAPI で仕組みを作るのは容易です。ただ、そういった使い方が認知される前に、Context に useState のデータを流してナイアガラの滝にしてしまう方法が広まってしまったのです。

有名どころの状態管理用ライブラリは内部で再レンダリング用のディスパッチャー管理をきちんとやっているのですが、何故か ContextAPI を直接使う場合はその手法がイマイチ認知されていません。

ContextAPI に useSelector を実装する例

前回の解説で使ったプログラムは、分かりやすいようにカスタムフックなどを作らず、生のまま使っていました。今回は汎用的な使い方が出来るように、ある程度機能を集約してライブラリ部分とメイン部分を分けています。

重要なのは Context 内の値をミュータブル(可変)で扱うことです。ミュータブルなオブジェクトの中に必要とされるデータと、各コンポーネントから集めたディスパッチャー(コンポーネントを再レンダリングさせるためのスイッチ)を格納します。こうすることによって、再レンダリングのタイミングを確実に制御出来ます。

今回は Redux の useSelector 相当の機能を実装するため、各コンポーネント内で作られる判定用の関数をコールバックする仕組みを作ります。戻り値に変化があった場合に再レンダリングのスイッチが入ります。

これ以上コードを大きくしたくないので入れませんでしたが、Reducer 相当の機能を入れると使い勝手は Redux にかなり近くなります。

import React, {
  Context,
  createContext,
  ReactNode,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";

/*
 * ContextAPIを汎用化
 */

type Manager<T> = {
  state: T;
  dispatches: Set<
    Readonly<
      [React.Dispatch<React.SetStateAction<unknown>>, (state: T) => unknown]
    >
  >;
};

type CustomContext<T> = {
  Provider: ({
    value,
    children,
  }: {
    children: ReactNode;
    value?: T;
  }) => React.FunctionComponentElement<React.ProviderProps<Manager<T>>>;
  _Provider: Context<Manager<T>>["Provider"];
  Consumer: Context<T>["Consumer"];
  displayName?: string | undefined;
};

const createManager = <T,>(state?: T) => ({
  state: state as T,
  dispatches: new Set<
    Readonly<
      [React.Dispatch<React.SetStateAction<unknown>>, (state: T) => unknown]
    >
  >(),
});

const createCustomContext: {
  <T>(state: T): CustomContext<T>;
  <T>(): CustomContext<T | undefined>;
} = <T,>(state?: T): CustomContext<T> => {
  const context = createContext<Manager<T>>(undefined as never);
  const customContext = context as unknown as CustomContext<T>;
  customContext._Provider = context.Provider;
  customContext.Provider = ({
    value,
    children,
  }: {
    children: ReactNode;
    value?: T;
  }) => {
    const manager = useRef(createManager<T>(value || state)).current;
    return React.createElement(
      customContext._Provider,
      { value: manager },
      children
    );
  };
  return customContext;
};

const useSelector = <T, K>(
  context: CustomContext<T>,
  selector: (state: T) => K
) => {
  const manager = useContext<Manager<T>>(
    context as unknown as Context<Manager<T>>
  );
  const [state, dispatch] = useState(() => selector(manager.state));
  useEffect(() => {
    const v = [
      dispatch as React.Dispatch<React.SetStateAction<unknown>>,
      selector,
    ] as const;
    manager.dispatches.add(v);
    dispatch(selector(manager.state));
    return () => {
      manager.dispatches.delete(v);
    };
  }, [manager]);
  return state;
};
const useDispatch = <T,>(context: CustomContext<T>) => {
  const manager = useContext<Manager<T>>(
    context as unknown as Context<Manager<T>>
  );
  const { dispatches } = manager;
  return (state: T | ((state: T) => T)) => {
    const newState =
      typeof state === "function"
        ? (state as (state: T) => T)(manager.state)
        : state;
    if (newState !== state) {
      manager.state = newState;
      dispatches.forEach(([dispatch, selector]) =>
        dispatch(selector(manager.state))
      );
    }
  };
};

/*
 * 利用部分
 */

const context = createCustomContext<{ [key: string]: number }>(
  undefined as never
);

const Component = ({ name }: { name: string }) => {
  console.log(name);
  const value = useSelector(context, (v) => v[name]);
  return (
    <div>
      {name}:{value}
    </div>
  );
};

const Buttons = () => {
  console.log("Buttons");
  const dispatch = useDispatch(context);
  return (
    <>
      <button
        onClick={() => {
          dispatch((v) => ({ ...v, A: (v["A"] || 0) + 1 }));
        }}
      >
        A
      </button>
      <button
        onClick={() => {
          dispatch((v) => ({ ...v, B: (v["B"] || 0) + 1 }));
        }}
      >
        B
      </button>
      <button
        onClick={() => {
          dispatch((v) => ({ ...v, C: (v["C"] || 0) + 1 }));
        }}
      >
        C
      </button>
    </>
  );
};

const Page = () => {
  console.log("Main");
  const value = useRef<{}>({ C: 100 }).current;
  return (
    <>
      <context.Provider value={value}>
        <Buttons />
        <Component name="A" />
        <Component name="B" />
        <Component name="C" />
        <Component name="A" />
        <Component name="B" />
        <Component name="C" />
      </context.Provider>
    </>
  );
};

export default Page;

動作

初期値設定の確認用に C だけ 100 からスタートします
出力結果は前回同様、ボタンに対応するコンポーネントのみが再レンダリングされます

まとめ

ContextAPI を他の状態管理ライブラリと比較したとき、「無駄な再レンダリングの元」や「複数の Context にデータを小分けしないと使えない」という誤解が今回の内容で解ければ幸いです。やろうと思えば Redux のように再レンダリングを局所的に行えます。使い方次第でいくらでも便利になるのです。

GitHubで編集を提案

Discussion

ログインするとコメントできます