こんにちは。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つあります。
- Contextを分割する
- 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を分割する
countとsetCountを別々のContextにすることで、countが更新されたときにCounterのみがレンダリングされ、ButtonContainerはレンダリングされなくなります。
ButtonContainerが利用するsetCountはcountの変更によって変動しない静的な関数であるため、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化」