42
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React の Context を分割することで不要な再レンダリングを防ぐ 【constate の紹介】

Last updated at Posted at 2021-05-09

要点

React の Context の更新による不要な再レンダリングを防ぐ方法として、React.memo や useMemo を利用する方法以外に、Context(Context オブジェクト)を分割するという方法がありますが、constate というライブラリを使うと、Context(Context オブジェクト)を簡単に分割できます。
constate は React Context 周りの API のラッパーを提供する小さな(1KB未満の)ライブラリです。
constate の類似ライブラリとして、unstated-next が挙げられますが、こちらのライブラリでは、Context の分割を行っていないので、「Context(Context オブジェクト)の分割」により再レンダリングを防ぐことはできません。

React の Context を分割するとはどういうことか、分割すると何が良いのか

Context の分割に関しては、既に 参考記事1 にて説明されており、このセクション(見出し1)は その記事と重複する内容になっていますが、当記事は constate の README からリンクされているサンプルコード をベースとしたコードで説明しており、そのサンプルコードと比較しやすい形にしています。

Context が分割されていない場合

下図のような、数字(初期値は 10 )を表示するコンポーネントと
+ボタン(インクリメントするだけ)を表示するコンポーネント を作ります。
image.png
この程度のコンポーネントにしては少し大袈裟なコードですが説明用のためご容赦ください。
全てのコードはこちら(CodeSandbox)
(↑GitHubレポジトリ更新してもCodeSandboxに反映されなくて困っています...)

App.jsx
import * as React from "react";

// Context
const Context = React.createContext({});

// カスタムフック
function useCounter({ initialCount = 0 } = {}) {
  const [count, setCount] = React.useState(initialCount);
  const increment = React.useCallback(() => setCount((c) => c + 1), []);
  return { count, increment };
}

// カスタムフック を ラップ する プロバイダ
const CounterProvider = ({ children, initialCount }) => {
  const value = useCounter({ initialCount });
  return <Context.Provider value={value}>{children}</Context.Provider>;
};

// プロバイダ から count を取り出す カスタムフック
const useCount = () => {
  const value = React.useContext(Context);
  return value.count;
};

// プロバイダ から increment を取り出す カスタムフック
const useIncrement = () => {
  const value = React.useContext(Context);
  return value.increment;
};

// ボタンコンポーネント
function IncrementButton() {
  console.log("render IncrementButton");
  const increment = useIncrement();
  return <button onClick={increment}>+</button>;
}

// カウントコンポーネント
function Count() {
  console.log("render Count");
  const count = useCount();
  return <span>{count}</span>;
}

function App() {
  return (
    <CounterProvider initialCount={10}>
      <Count />
      <IncrementButton />
    </CounterProvider>
  );
}

export default App;

2回 +ボタンを押下すると、本来 再レンダリング されるべきでない(※) ボタンコンポーネント(IncrementButton)が2回レンダリング されていることがわかります。
image.png
※ ボタンコンポーネント(IncrementButton)は 参照が変更されるcountに依存する必要はなく、React.useCallback でメモ化された参照が変更されない increment関数のみに依存しているためです。

Context が分割されている場合

全てのコードはこちら(CodeSandbox)

App.jsx
import * as React from "react";

// Context を、参照が変更する count ステイト用 と 参照が変わらない increment 関数用 の2つ作成する
const CountContext = React.createContext({});
const IncrementContext = React.createContext({});

// useCounter 変更なし

// 2つのプロバイダーをネストして1つのプロバイダーにする
const CounterProvider = ({ children, initialCount }) => {
  const { count, increment } = useCounter({ initialCount });
  return (
    <CountContext.Provider value={count}> {/* count ステイト用 */}
      <IncrementContext.Provider value={increment}> {/* increment 関数用*/}
        {children}
      </IncrementContext.Provider>
    </CountContext.Provider>
  );
};

const useCount = () => {
  return React.useContext(CountContext); // count ステイト用のコンテクストを使う
};

const useIncrement = () => {
  return React.useContext(IncrementContext); // increment 関数用のコンテクストを使う
};

// IncrementButton 変更なし

// Count 変更なし

// App 変更なし

export default App;

2回 +ボタンを押下すると、ボタンコンポーネント(IncrementButton)は再レンダリングされないことがわかります。
image.png

constate を使うと何が良いのか

Context の分割に constate を使った場合

全てのコードはこちら(CodeSandbox)

App.jsx
import * as React from "react";
import constate from "constate"; // 追加

// ❗ React.createContext は不要

// useCounter 変更なし

// constate  の利用
const [CounterProvider, useCount, useIncrement] = constate(
  useCounter,
  (value) => value.count,
  (value) => value.increment
);
/* 
  constate の使い方:
    第一引数:カスタムフック
    残余引数:セレクター関数(第一引数に渡したカスタムフックの戻り値から欲しい値を取り出す関数)
    戻り値:配列(第一要素はプロバイダー、
                 第二要素以降は「カスタムフックの戻り値をセレクターで取り出した値」を取得するフック)
*/

// IncrementButton 変更なし

// Count 変更なし

// App 変更なし

export default App;

前述の「Context が分割されている場合」と比較すると、ボタンコンポーネントの再レンダリングを防ぐという結果は変わりませんが、コードがシンプルになります。Context が増えてもプロバイダーをたくさんネストさせて書かなくてもよくなります。
コードの差分(Github)

constate は型もバッチリ

前述のコード(CodeSandbox) は App.tsx(TypeScript)ではなく、App.jsx(JavaScript) ですが、CodeSandbox のエディタで(もちろんVSCodeでも)しっかりコード補完が効きます。
image.png
image.png

類似ライブラリ(unstated-next)との比較

(2021/5/10 現在)
constate の類似ライブラリとして、unstated-next が挙げられます。unstated-next は Qiita にて複数の記事で取り上げられている一方、constate 主体で記述されている記事は本記事執筆時にはありません。
しかし、npm trends によれば、ダウンロード数において constate は unstated-next を上回っています。

また unstated-next には、Context を分割する機能はありません。

なぜ React の Context を分割すると再レンダリングを防ぐことができるのか

プロバイダーを使っているコンポーネントが再レンダリングされた時、React は<Context.Provider value={something}>something の参照の変更をチェックし、変更されている場合にコンシューマ(※)を再レンダリングするからです。
useContex(Context)等で Context からデータを取得しているコンポーネント

上述の「Context が分割されている場合」だと<IncrementContext.Provider value={increment}>となっており、increment はメモ化されて参照が変更されないため、コンシューマであるボタンコンポーネント(IncrementButton)は再レンダリングされません。

一方、「Context が分割されていない場合」だと <Context.Provider value={value}>value は、count がインクリメントされれば参照が変わるので、ボタンコンポーネント(IncrementButton)は再レンダリングされます。

// useCounter の戻り値は increment される度に参照が変わる
function useCounter({ initialCount = 0 } = {}) {
  const [count, setCount] = React.useState(initialCount);
  const increment = React.useCallback(() => setCount((c) => c + 1), []);
  return { count, increment }; // オブジェクトリテラル を使っているので実行毎に useCounter の戻り値の参照が変わる
//return React.useMemo(() => { count, increment }, [count, increment])
// ↑ このようにしても、increment されれば count の参照が変わるので useCounter の戻り値の参照が変わる
// ↑ count を useMemo 第二引数(Dependency List)から除去すると、
//   Count コンポーネントは再レンダリングされなくなり、カウンター機能が失われる
}

// useCounter の戻り値(value)は increment されるたびに参照が変わるので
// Context のコンシューマである Count と IncrementButton もそのたびに再レンダリングされる
const CounterProvider = ({ children, initialCount }) => {
  const value = useCounter({ initialCount });
  return <Context.Provider value={value}>{children}</Context.Provider>;
};

補足:Context の分割 vs React.memo / React.useMemo (私見)

参考記事1 で説明されている通り、Context を分割せずとも React.memo や React.useMemo を利用して、コンシューマの不要な再レンダリングを防ぐことができます。
私は Context の分割で React.memo / React.useMemo が不要になるのなら、積極的に Context を分割すべきと考えています。上流のコンポーネントのプロバイダーの参照比較を数個に増やしただけで、下流の数十個以上のコンシューマにおける参照比較を省略することが期待できるからです(分割後の Context の数とそれらのコンシューマの数が同じような数であることが確定的である場合はこの限りではありませんが)。コンシューマの数が増えれば増えるほど React.memo や React.useMemo 自体のコスト(参照比較のコスト)が増大し、無視できない遅延が発生するケースがあります。(私が Context をちゃんと学習しようと思ったきっかけがまさにこのケースでした。)

なお、参考記事1 でも

何らかの理由で Context を分割できない場合、後述のReact.memoかuseMemoを利用した方法で再レンダリングを防ぐ。

と記載されており、Context の分割を優先すべきことが示唆されています。

おまけ

jotai とちょっと比較する

コードはこちら(CodeSandbox)
constateコードとの差分(Github)

参考

  1. React の Context の更新による不要な再レンダリングを防ぐ 〜useContext を利用した時に発生する不要な再レンダリングを防ぐ方法に関して〜
  2. Reactのレンダリングに関する完全ガイド #context-の基本
  1. React の Context の更新による不要な再レンダリングを防ぐ 〜useContext を利用した時に発生する不要な再レンダリングを防ぐ方法に関して〜 2 3

42
38
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?