React Contextのパフォーマンス改善:Context分割とmemo化の使い分け

こんにちは。Enjoy IT Life管理人の@nishina555です。

React Contextは便利なAPIですが、使い方を誤ると不要な再レンダリングが発生し、パフォーマンスに影響を及ぼすことがあります。

この記事では、Contextの再レンダリング問題とその改善方法について、実際のコードを交えて解説します。

サンプルコードは以下のリポジトリで公開しています。

サンプルアプリケーションの構成

カウンターアプリを例に説明します。CounterProviderでカウントの状態を管理し、Counterコンポーネントで表示、ButtonContainerコンポーネントでインクリメント・デクリメントを行います。

// src/components/shared/CounterProvider.tsx
import { createContext, FC, ReactNode, useState, Dispatch, SetStateAction} from "react";

type Props = {
  children: ReactNode;
};

type CounterContextType = {
  count: number;
  setCount: Dispatch<SetStateAction<number>>;
}

export const CounterContext = createContext<CounterContextType>({} as CounterContextType);

export const CounterProvider: FC<Props> = ({ children }) => {
  const [count, setCount] = useState<number>(0);
  return (
    <CounterContext.Provider value={{count, setCount}}>
      {children}
    </CounterContext.Provider>
  );
};
// App.tsx
import { ButtonContainer } from './components/ButtonContainer';
import { Text } from './components/shared/Text';
import { Counter } from './components/Counter';
import { CounterProvider } from './components/shared/CounterProvider';

function App() {
  return (
    <CounterProvider>
      <div className="App">
        <Text label={'Text'} />
        <ButtonContainer />
        <Counter />
      </div>
    </CounterProvider>
  );
}

export default App;
// src/components/Counter.tsx
import { FC, useContext } from "react"
import { CounterContext } from "./shared/CounterProvider"
import { Text } from "./shared/Text"

export const Counter: FC = () => {
  const { count } = useContext(CounterContext)
  console.log('Counter')
  return (
    <>
      <div>{count}</div>
      <Text label={'CountText'} />
    </>
  )
}
// src/components/ButtonContainer.tsx
import { FC, useContext, useCallback } from "react"
import { CounterContext } from "./shared/CounterProvider"
import { Text } from "./shared/Text"
import { Button } from "./shared/Button"

export const ButtonContainer: FC = () => {
  const { setCount } = useContext(CounterContext)
  console.log('Button Container')

  const increment = useCallback(() => {
    setCount((prev) => prev + 1);
  }, [setCount]);

  const decrement = useCallback(() => {
    setCount((prev) => prev - 1);
  }, [setCount]);

  return (
    <>
      <Button onClick={increment} label={'+'} />
      <Button onClick={decrement} label={'-'} />
      <Text label={'ButtonText'} />
    </>
  )
}

Contextの問題点

上記の構成では、Contextの値(count)が更新されると、Contextを利用しているすべてのConsumerが再レンダリングされます。つまり、ボタンをクリックするとCounterだけでなくButtonContainerもレンダリングされてしまいます。

パフォーマンス改善の方法

改善方法は大きく2つあります。

  1. Contextを分割する
  2. React.memoで子コンポーネントをmemo化する

方法1: React.memoでmemo化する

React.memoを利用すると、Propsの更新がない限り再レンダリングが行われなくなります。Propsの比較を利用したメモ化なので、useContextで取得した値をPropsとして渡す子コンポーネントのパフォーマンスチューニングに有効です。

今回のケースでは、TextコンポーネントやButtonコンポーネントをmemo化することで不要な再レンダリングを防げます。

// src/components/shared/Text.tsx
import { memo, FC } from "react"

type Props = {
  label: string
}

export const Text: FC<Props> = memo(({ label }) => {
  console.log(label)
  return (
    <div>{label}</div>
  )
})
// src/components/shared/Button.tsx
import { FC, memo } from "react"

type Props = {
  onClick: () => void,
  label: string
}

export const Button: FC<Props> = memo(({ onClick, label }) => {
  console.log('Shared Button')
  return (
    <button onClick={onClick}>{label}</button>
  )
})

ただし、React.memoはあくまでPropsの比較を利用したメモ化です。共通したContextを利用しているButtonContainer自体は、countが更新されるたびにContextの値の再取得が行われるため、React.memoではメモ化できません。

方法2: Contextを分割する

countsetCountを別々のContextにすることで、countが更新されたときにCounterのみがレンダリングされ、ButtonContainerはレンダリングされなくなります。

ButtonContainerが利用するsetCountcountの変更によって変動しない静的な関数であるため、ButtonContainerは一度レンダリングされた後は再レンダリングされません。親コンポーネントのButtonContainerが再レンダリングされないため、子のButtonコンポーネントもmemo化せずに再レンダリングを防げます。

// src/components/shared/CounterProvider.tsx(分割版)
import { createContext, FC, ReactNode, useState, Dispatch, SetStateAction} from "react";

type Props = {
  children: ReactNode;
};

export const CountContext = createContext<number>(0);
export const SetCountContext = createContext<Dispatch<SetStateAction<number>>>(() => {});

export const CounterProvider: FC<Props> = ({ children }) => {
  const [count, setCount] = useState<number>(0);
  return (
    <CountContext.Provider value={count}>
      <SetCountContext.Provider value={setCount}>
        {children}
      </SetCountContext.Provider>
    </CountContext.Provider>
  );
};
// src/components/Counter.tsx
import { FC, useContext } from "react"
import { CountContext } from "./shared/CounterProvider"
import { Text } from "./shared/Text"

export const Counter: FC = () => {
  const count = useContext(CountContext)
  console.log('Counter')
  return (
    <>
      <div>{count}</div>
      <Text label={'CountText'} />
    </>
  )
}
// src/components/ButtonContainer.tsx
import { FC, useContext, useCallback } from "react"
import { SetCountContext } from "./shared/CounterProvider"
import { Text } from "./shared/Text"
import { Button } from "./shared/Button"

export const ButtonContainer: FC = () => {
  const setCount = useContext(SetCountContext)
  console.log('Button Container')

  const increment = useCallback(() => {
    setCount((prev) => prev + 1);
  }, [setCount]);

  const decrement = useCallback(() => {
    setCount((prev) => prev - 1);
  }, [setCount]);

  return (
    <>
      <Button onClick={increment} label={'+'} />
      <Button onClick={decrement} label={'-'} />
      <Text label={'ButtonText'} />
    </>
  )
}

注意点: Contextの見直しは更新頻度をもとに検討する

「Contextに複数の値がまとめられている」=「アンチパターン」というわけではありません。React.memo を濫用していませんか? 更新頻度で見直す Provider 設計で解説されているように、更新頻度が低ければ複数の値をまとめてContextにセットする「モノリスProviderパターン」が最適になることもあります。

したがって、「まずはモノリスProviderパターンで実装し、更新頻度が高い値が観測されたらProviderを分割する」という流れが適切です。

改善方法の優先度としては、Contextの設計を見直す > memo化の順です。ただし、何らかの理由でContextを分割できない場合はmemo化で対応します。memo化によるオーバーヘッドはたかが知れているので、必要そうだと思ったところで適用してしまっても問題ありません。

補足: カスタムフックでリファクタリングする

Contextを利用するにはConsumer側で「Contextのimport」「useContextのimport」「useContextを使ったデータの取得」という手順が必要です。これらをカスタムフックにラップすることで、Consumer側のコードがすっきりします。

この考え方についてはReact Context を export するのはアンチパターンではないかと考えるで詳しく解説されています。

まとめ

  • 更新頻度が高い値を含むオブジェクトがひとつのContextになっている場合は、Contextの分割を検討する
  • 更新頻度が高くなければ、ひとつのContextで管理しても問題ない
  • Contextの分割が難しい場合は、React.memoで子コンポーネントをmemo化して対応する
  • 改善の優先度は「Context設計の見直し > memo化」

参考

タグ: React , パフォーマンス