153
Help us understand the problem. What are the problem?

posted at

updated at

React の Context の更新による不要な再レンダリングを防ぐ 〜useContext を利用した時に発生する不要な再レンダリングを防ぐ方法に関して〜

はじめに

React(v16.12.0) の Context の更新による不要な再レンダリングを防ぐ方法についての備忘録です。

useContextフックなどで利用する Context のデメリットとして

「Context を更新したら、その Context を利用しているコンポーネントがすべて再レンダリングされてしまう」

ということが記載されている時がありますが、関数コンポーネントであれば再レンダリングを防げます(クラスコンポーネントでもできるかも)。

ということで、この記事は関数コンポーネントを対象としています。

また、デモは CodeSandbox 上に置いてあります。編集して動作を確認してみると理解が深まると思います。

useContext を利用する上で、理解しておく必要がある概念(用語)

  • Context(コンテキスト)
  • Context オブジェクト
  • Provider(プロバイダ)
  • Consumer(コンシューマ)

Context(コンテキスト)

文脈によって意味合いが異なるが、React に関しては以下のいずれかを指していることが多い。

  1. Props を利用せずに様々な階層のコンポーネントに値を共有する React の仕組みや API のこと
  2. Context オブジェクトのこと
  3. Context オブジェクトの値のこと

「1.」の意味の場合、「React Context」、「React Context API」や「Context API」などと記載されていることもある。

「Context を利用する」とは

前述の「1.」の意味の「Context を利用する」という表現を、より具体的な表現にすると以下の通り。

  • 「Context という React の仕組み(API)を利用して、Props を利用せずに様々な階層のコンポーネントに値を渡せるようにする」
  • React.createContextで Context オブジェクトと Provider を定義し、useContextで Consumer を定義して、Props を利用せずに様々な階層のコンポーネントに値を渡せるようにする」

Context オブジェクト

React.createContextという React の API(メソッド)の戻り値。

Context オブジェクトとuseContextを利用することで、Props を利用せずに様々な階層のコンポーネントに値を渡せる(関数コンポーネントの場合)。

Provider(プロバイダ)

Consumer に値を共有するコンポーネントのこと。Context オブジェクトが保持している。

「Provider コンポーネント」と記載されていることもある。

以下の場合、MyContext.Providerコンポーネントが Provider に当たる。

import React, { createContext } from "react";

const MyContext = createContext();

export default function App() {
  const name = "soarflat";

  return <MyContext.Provider value={name}></MyContext.Provider>;
}

valueプロパティの値が Context オブジェクトが保持している値であり、useContextを利用することで、この値を取得できる。

Consumer(コンシューマ)

useContextなどを利用して Context オブジェクトから値を取得するコンポーネントのこと。

「Consumer コンポーネント」と記載されていることもある。

以下の場合、Child1コンポーネントが Consumer に当たる。

import React, { createContext, useContext } from "react";

const MyContext = createContext();

// Consumer
function Child1() {
  const name = useContext(MyContext);

  return <h1>{name}</h1>;
}

// Consumer ではない
function Child2() {
  return <h2>Not Consumer</h2>;
}

export default function App() {
  const name = "Consumer";

  return (
    <MyContext.Provider value={name}>
      <Child1 />
      <Child2 />
    </MyContext.Provider>
  );
}

Child2コンポーネントは Context オブジェクトから値を取得していないので、Consumer ではない。

Provider 内のすべての Consumer は、Provider のvalueプロパティが更新される度に再レンダリングされる

以下は Context(Context オブジェクトの値)の更新が原因で、不要な再レンダリングが発生しているデモ。

Provider 内のすべての Consumer は、Provider のvalueプロパティ(Context オブジェクトの値)が更新される度に再レンダリングされる。

extra-rerenders-with-react-context.gif
デモを見る

import React, { createContext, useContext, useReducer } from "react";

const CountContext = createContext();

function countReducer(state, action) {
  switch (action.type) {
    case "increment": {
      return { count: state.count + 1 };
    }
    case "decrement": {
      return { count: state.count - 1 };
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`);
    }
  }
}

function CountProvider({ children }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });
  const value = {
    state,
    dispatch
  };

  return (
    // value(state か dispatch のどちらか)が更新したら、
    // CountContext.Provider 内のすべての Consumer が再レンダリングされる。
    <CountContext.Provider value={value}>{children}</CountContext.Provider>
  );
}

function Count() {
  console.log("render Count");
  // CountContext からは state のみを取得しているが、
  // dispatch が更新されても再レンダリングされる
  const { state } = useContext(CountContext);

  return <h1>{state.count}</h1>;
}

function Counter() {
  console.log("render Counter");
  // CountContext からは dispatch のみを取得しているが、
  // state が更新されても再レンダリングされる
  const { dispatch } = useContext(CountContext);

  return (
    <>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  );
}

export default function App() {
  return (
    <CountProvider>
      <Count />
      <Counter />
    </CountProvider>
  );
}

Counterコンポーネントが取得しているdispatchは更新されることはないが、stateが更新される度にCounterコンポーネントも再レンダリングされてしまう。

上記のデモは特に問題ないが、以下の場合、パフォーマンスの問題を引き起こす可能性がある。

  • 不要に再レンダリングされる Consumer の数が多い
  • 不要に再レンダリングされる Consumer や、その Consumer の子コンポーネントのレンダリングコストが高い

Context(Context オブジェクトの値)の更新による不要な再レンダリングを防ぐ方法

Context の更新による不要な再レンダリングを防ぐ方法は以下の3つ。

  1. Context(Context オブジェクト)を分割する
  2. React.memoを利用する
  3. useMemoを利用する

Context(Context オブジェクト)を分割する

以下は Context を分割して不要な再レンダリングを防いでいるデモ。

split-contexts.gif
デモを見る

import React, { createContext, useContext, useReducer } from "react";

const CountStateContext = createContext();
const CountDispatchContext = createContext();

function countReducer(state, action) {
  switch (action.type) {
    case "increment": {
      return { count: state.count + 1 };
    }
    case "decrement": {
      return { count: state.count - 1 };
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`);
    }
  }
}

function CountProvider({ children }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });

  // CountStateContext.Provider の value が更新したら、
  // CountStateContext の値を取得している全ての Consumer が再レンダリングされる。
  // CountDispatchContext.Provider の value が更新したら、
  // CountDispatchContext の値を取得している全ての Consumer が再レンダリングされる。
  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  );
}

function Count() {
  console.log("render Count");
  // state と dispatch を保持する Context オブジェクトが異なるので、
  // dispatch が更新されてもこのコンポーネントは再レンダリングされない。
  const state = useContext(CountStateContext);

  return <h1>{state.count}</h1>;
}

function Counter() {
  console.log("render Counter");
  // state と dispatch を保持する Context オブジェクトが異なるので、
  // state が更新されてもこのコンポーネントは再レンダリングされない。
  const dispatch = useContext(CountDispatchContext);

  return (
    <>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  );
}

export default function App() {
  return (
    <CountProvider>
      <Count />
      <Counter />
    </CountProvider>
  );
}

statedispatchを保持する Context を分割したので、stateを更新してもCounterコンポーネントは再レンダリングされない。

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

React.memo を利用する

以下のデモはReact.memoを利用して不要な再レンダリングを防いでいるデモ。

using-memo.gif
デモを見る

import React, { createContext, useContext, useReducer } from "react";

const CountContext = createContext();

function countReducer(state, action) {
  switch (action.type) {
    case "increment": {
      return { count: state.count + 1 };
    }
    case "decrement": {
      return { count: state.count - 1 };
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`);
    }
  }
}

function CountProvider({ children }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });
  const value = {
    state,
    dispatch
  };

  return (
    // value(state か dispatch のどちらか)が更新したら、
    // CountContext.Provider 内のすべての Consumer が再レンダリングされる。
    <CountContext.Provider value={value}>{children}</CountContext.Provider>
  );
}

function Count() {
  console.log("render Count");
  // CountContext からは state のみを取得しているが、
  // dispatch が更新されても再レンダリングされる
  const { state } = useContext(CountContext);

  return <h1>{state.count}</h1>;
}

function Counter() {
  console.log("render Counter");
  // CountContext からは dispatch のみを取得しているが、
  // state が更新されても再レンダリングされる
  const { dispatch } = useContext(CountContext);

  // CountContext.Provider の value の更新による Counter コンポーネントの
  // 再レンダリングは避けられない。そのため、このコンポーネントは CountContext から値を
  // 取得するだけにして、メモ化したコンポーネントに取得した dispatch を渡すようにする。
  return <DispatchButton dispatch={dispatch} />;
}

// dispatch を Props として受け取るコンポーネントをメモ化し、不要な再レンダリングを防ぐ
const DispatchButton = React.memo(({ dispatch }) => {
  console.log("render DispatchButton");

  return (
    <>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  );
});

export default function App() {
  return (
    <CountProvider>
      <Count />
      <Counter />
    </CountProvider>
  );
}

Context を分割していないため、stateを更新してもCounterコンポーネントは再レンダリングされる。

そのため、CounterコンポーネントはCountContextの値を取得するだけにする。

そして、CountContextの値を利用するコンポーネント(DispatchButton)を切り出し、React.memoでメモ化する。

このようにすれば、stateを更新してもDispatchButtonコンポーネントは再レンダリングされない。

useMemo を利用する

以下のデモはuseMemoを利用して不要な再レンダリングを防いでいるデモ。

using-memo.gif
デモを見る

import React, { createContext, useContext, useReducer, useMemo } from "react";

const CountContext = createContext();

function countReducer(state, action) {
  switch (action.type) {
    case "increment": {
      return { count: state.count + 1 };
    }
    case "decrement": {
      return { count: state.count - 1 };
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`);
    }
  }
}

function CountProvider({ children }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });
  const value = {
    state,
    dispatch
  };

  return (
    // value(state か dispatch のどちらか)が更新したら、
    // CountContext.Provider 内のすべての Consumer が再レンダリングされる。
    <CountContext.Provider value={value}>{children}</CountContext.Provider>
  );
}

function Count() {
  console.log("render Count");
  // CountContext からは state のみを取得しているが、
  // dispatch が更新されても再レンダリングされる
  const { state } = useContext(CountContext);

  return <h1>{state.count}</h1>;
}

function Counter() {
  console.log("render Counter");
  // CountContext からは dispatch のみを取得しているが、
  // state が更新されても再レンダリングされる
  const { dispatch } = useContext(CountContext);

  // CountContext.Provider の value の更新による Counter コンポーネントの
  // 再レンダリングは避けられない。そのため dispatch を利用するレンダリング結果(計算結果)を
  // メモ化し、不要な再レンダリングを防ぐ。
  return useMemo(() => {
    console.log("rerender Counter");
    return (
      <>
        <button onClick={() => dispatch({ type: "decrement" })}>-</button>
        <button onClick={() => dispatch({ type: "increment" })}>+</button>
      </>
    );
  }, [dispatch]);
}

export default function App() {
  return (
    <CountProvider>
      <Count />
      <Counter />
    </CountProvider>
  );
}

Context を分割していないため、stateを更新してもCounterコンポーネントは再レンダリングされる。

そのため、useMemoCountContextの値を利用するレンダリング結果をメモ化する。

このようにすれば、stateを更新してCounterコンポーネントが再レンダリングされた時は、メモ化されたレンダリング結果を返す。

結果として、stateを更新しても不要な再レンダリングは発生しない。

終わり

Context は便利な API ですが、使い方によってはパフォーマンスの問題を引き起こす可能性があります。

そのため、再レンダリングが発生する条件と、再レンダリングを防ぐ方法を理解した上で利用しましょう。

本記事以外にも React に関連する記事を書いておりますので、興味があればそちらもどうぞ。

参考

お知らせ

Udemy で webpack の講座を公開したり、Kindle で技術書を出版しています。

Udemy:
webpack 最速入門10,800 円 -> 2,400 円

Kindle(Kindle Unlimited だったら無料):
React Hooks 入門(500 円)

興味を持ってくださった方はご購入いただけると大変嬉しいです。よろしくお願いいたします。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
153
Help us understand the problem. What are the problem?