ReactのContextを分けて余計な再描画を減らす方法

​​React​memo,TypeScript,useContext,useMemo

1つの Context にテーマもカウンターも更新用の関数も入れると、どれか1つが変わるたびに、その Context を読むコンポーネントがすべて描き直されます。変わるタイミングが違う値を別々の Context に分けると、変わった値を読むコンポーネントだけが描き直されるようになります。

筆者は検証環境で、同じ画面を5通りの書き方で作り、ボタンを押したときに各コンポーネントが何回描画されたかを数えました。useMemo で値を包む書き方や、Context を読む側を memo で包む書き方では足りない理由も、あわせて確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。描画回数は、各コンポーネントの関数の先頭で数を足して数えています。StrictMode は使っていません。

1つの Context に全部入れた書き方

まず、テーマ(theme)とカウンター(count)、そしてそれぞれを更新する関数を、1つの AppContext に入れました。Context を読むコンポーネントは4つです。テーマを表示する ThemeLabel、カウントを表示する CountLabel、カウントを増やすボタン IncrementButton、テーマを切り替えるボタン ThemeButton です。Context を読まない見出しの Header も置きました。

tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import { countRender } from './renders';

type AppValue = {
  theme: 'light' | 'dark';
  count: number;
  increment: () => void;
  toggleTheme: () => void;
};

// テーマもカウンターも、更新する関数も、1つの Context に入れる
const AppContext = createContext<AppValue | null>(null);

function useApp() {
  const value = useContext(AppContext);
  if (!value) throw new Error('AppProvider の中で使ってください');
  return value;
}

function AppProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [count, setCount] = useState(0);
  const value: AppValue = {
    theme,
    count,
    increment: () => setCount((c) => c + 1),
    toggleTheme: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
  };
  return <AppContext value={value}>{children}</AppContext>;
}

function ThemeLabel() {
  countRender('ThemeLabel');
  const { theme } = useApp();
  return <p id="theme">テーマ: {theme}</p>;
}

function IncrementButton() {
  countRender('IncrementButton');
  const { increment } = useApp();
  return <button id="inc" onClick={increment}>+1</button>;
}

// CountLabel・ThemeButton・Header も同じように書く(省略)

export function App() {
  return (
    <AppProvider>
      <Header />
      <ThemeLabel />
      <CountLabel />
      <IncrementButton />
      <ThemeButton />
    </AppProvider>
  );
}

React 19 では、<AppContext.Provider> の代わりに <AppContext> をそのまま Provider として書けます(createContext のリファレンスにある、Provider の説明)。

この画面で「+1」を3回押し、そのあとテーマ切り替えを1回押しました。描画回数は、最初の表示のあとと、テーマ切り替えを押す前に、それぞれ0に戻してから数えています。

text
$ node run.mjs ./out/AppOne.mjs
./out/AppOne.mjs
  最初の表示: 「テーマ: light」「カウント: 0」
    描画回数: Header 1 / ThemeLabel 1 / CountLabel 1 / IncrementButton 1 / ThemeButton 1
  +1 を3回押したあと: 「テーマ: light」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 3 / CountLabel 3 / IncrementButton 3 / ThemeButton 3
  テーマ切り替えを1回押したあと: 「テーマ: dark」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 1 / CountLabel 1 / IncrementButton 1 / ThemeButton 1

カウントが変わっただけなのに、テーマを表示する ThemeLabel も、2つのボタンも、3回ずつ描き直されました。テーマを切り替えたときも、4つすべてが1回ずつ描き直されています。

useContext のリファレンスの注意事項によると、Provider が受け取る value が変わると、React はその Context を読むコンポーネントをすべて描き直します。値が変わったかどうかは、前の値と Object.is で比べて決めます。今回の結果のとおり、コンポーネントが使うのがオブジェクトの一部だけでも、オブジェクトが変われば描き直されます。

useMemo で値を包んでも、この場合は減らない

useContext のリファレンスにある、オブジェクトや関数を渡すときの最適化の説明には、関数を useCallback で、値のオブジェクトを useMemo で包む方法が書かれています。同じように直した版も試しました。変えたのは Provider の部分だけです。

tsx
function AppProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const toggleTheme = useCallback(
    () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
    [],
  );
  // 値のオブジェクトを useMemo で包む。theme か count が変わると作り直される
  const value = useMemo(
    () => ({ theme, count, increment, toggleTheme }),
    [theme, count, increment, toggleTheme],
  );
  return <AppContext value={value}>{children}</AppContext>;
}
text
$ node run.mjs ./out/AppOneMemo.mjs
(前略)
  +1 を3回押したあと: 「テーマ: light」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 3 / CountLabel 3 / IncrementButton 3 / ThemeButton 3
  テーマ切り替えを1回押したあと: 「テーマ: dark」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 1 / CountLabel 1 / IncrementButton 1 / ThemeButton 1

結果は、包む前とまったく同じでした。count が変わると、useMemo は新しいオブジェクトを作り直します。オブジェクトが変われば、それを読むコンポーネントはすべて描き直されます。

useMemo が効くのは、Context の中身は変わっていないのに、Provider が描き直されたときです。これを確かめるため、カウントと増やす関数だけを入れた Context で、別の小さなサンプルを作りました。useMemo を使わない Provider と、使う Provider の2つです。

tsx
// useMemo を使わない Provider(描画のたびに新しいオブジェクトと関数を作る)
function InlineProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  const value = { count, increment: () => setCount((c) => c + 1) };
  return <CountContext value={value}>{children}</CountContext>;
}

// useMemo で値を包む Provider(count が変わったときだけ作り直す)
function MemoProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  const value = useMemo(
    () => ({ count, increment: () => setCount((c) => c + 1) }),
    [count],
  );
  return <CountContext value={value}>{children}</CountContext>;
}

// この CountContext は { count, increment } を入れる別の Context。CountLabel・Page・親の App は省略

それぞれの Provider を、Context とは関係のない state を持つ親の下に置き、親だけを3回描き直しました。Provider の下には、カウントを表示する CountLabel を入れたページ Page を置き、Page は memo で包んでいます。包まないと、親が描き直されたときに Page ごと描き直され、CountLabel の描き直しが Context によるものか区別できなくなります。

text
$ node run-parent.mjs
AppParentInline: 親を3回描き直したあと「親を描き直す(3)」 / 描画回数: Page 0 / CountLabel 3
AppParentMemo: 親を3回描き直したあと「親を描き直す(3)」 / 描画回数: Page 0 / CountLabel 0

useMemo を使わない Provider の版(AppParentInline)では、ページは描き直されなかったのに、その中で Context を読む CountLabel は3回描き直されました。Provider が描き直されるたびに、新しいオブジェクトが value に渡されたからです。useMemo で包んだ Provider の版(AppParentMemo)では、CountLabel は0回でした。

ポイント

useMemo で防げるのは「中身が同じなのに、オブジェクトが新しくなる」ことによる描き直しです。中身の一部が本当に変わったときに、関係のないコンポーネントまで描き直されるのは防げません。

読む側を memo で包んでも効かない

ThemeLabel を memo で包んだ版も試しました(Provider は useMemo で包んだ版と同じです)。

tsx
// Context を読むコンポーネントそのものを memo で包んでも、効くかどうか
const ThemeLabel = memo(function ThemeLabel() {
  countRender('ThemeLabel');
  const { theme } = useApp();
  return <p id="theme">テーマ: {theme}</p>;
});
text
$ node run.mjs ./out/AppMemoConsumer.mjs
(前略)
  +1 を3回押したあと: 「テーマ: light」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 3 / CountLabel 3 / IncrementButton 3 / ThemeButton 3
(後略)

ThemeLabel は、包む前と同じく3回描き直されました。memo のリファレンスにあるとおり、memo が比べるのは親から渡される props だけです。読んでいる Context が変われば、memo で包んでいても描き直されます。

Context を分けると、関係するものだけが描き直される

変わるタイミングが違う値ごとに、Context を分けました。テーマは ThemeContext、カウントは CountContext、更新する関数は ActionsContext に入れます。関数をまとめたオブジェクトは、useMemo で包んで作り直さないようにします。useState のリファレンスにある、set 関数の注意事項によると、useState の更新関数(setCount など)は描画のたびに変わりません。そのため、依存配列は空にしています。

tsx
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
import { countRender } from './renders';

type Theme = 'light' | 'dark';
type Actions = { increment: () => void; toggleTheme: () => void };

// 変わるタイミングが違う値を、別々の Context に分ける
const ThemeContext = createContext<Theme>('light');
const CountContext = createContext(0);
const ActionsContext = createContext<Actions | null>(null);

function useActions() {
  const actions = useContext(ActionsContext);
  if (!actions) throw new Error('AppProvider の中で使ってください');
  return actions;
}

function AppProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  const [count, setCount] = useState(0);
  // 更新する関数だけをまとめたオブジェクトは、最初の1回だけ作る
  const actions = useMemo<Actions>(
    () => ({
      increment: () => setCount((c) => c + 1),
      toggleTheme: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
    }),
    [],
  );
  return (
    <ActionsContext value={actions}>
      <ThemeContext value={theme}>
        <CountContext value={count}>{children}</CountContext>
      </ThemeContext>
    </ActionsContext>
  );
}

function ThemeLabel() {
  countRender('ThemeLabel');
  const theme = useContext(ThemeContext);
  return <p id="theme">テーマ: {theme}</p>;
}

function CountLabel() {
  countRender('CountLabel');
  const count = useContext(CountContext);
  return <p id="count">カウント: {count}</p>;
}

function IncrementButton() {
  countRender('IncrementButton');
  const { increment } = useActions();
  return <button id="inc" onClick={increment}>+1</button>;
}

// ThemeButton は useActions() から toggleTheme を読む。Header・App は1つの Context の版と同じ(省略)
text
$ node run.mjs ./out/AppSplit.mjs
./out/AppSplit.mjs
  最初の表示: 「テーマ: light」「カウント: 0」
    描画回数: Header 1 / ThemeLabel 1 / CountLabel 1 / IncrementButton 1 / ThemeButton 1
  +1 を3回押したあと: 「テーマ: light」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 0 / CountLabel 3 / IncrementButton 0 / ThemeButton 0
  テーマ切り替えを1回押したあと: 「テーマ: dark」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 1 / CountLabel 0 / IncrementButton 0 / ThemeButton 0

「+1」を3回押したとき、描き直されたのは CountLabel だけでした。テーマを切り替えたときは ThemeLabel だけです。2つのボタンは、どちらの操作でも描き直されませんでした。ボタンが読む ActionsContext の値は、今回の検証では最初に作ったオブジェクトのまま変わらなかったからです。ただし、useMemo のリファレンスの注意事項によると、React は理由があればキャッシュした値を捨てることがあります。useMemo は性能のための最適化として使い、捨てられても動きが変わらないようにしておきます。

「+1」を3回押したときの描画回数を、Context が1つの書き方と分けた書き方で比べた棒グラフ。1つのときは4つのコンポーネントがすべて3回、分けたときはカウントを表示するコンポーネントだけが3回で、ほかは0回
「+1」を3回押したときの、コンポーネントごとの描画回数

値と、値を更新する関数を別の Context に分ける形は、公式の学習ページにある、リデューサと Context を組み合わせる例でも使われています。この例では、タスクの一覧を TasksContext に、dispatch 関数を TasksDispatchContext に入れています。useReducer のリファレンスの注意事項によると、dispatch も、useState の更新関数と同じく、描画のたびに変わりません。

なお、Context を読まない Header は、どの書き方でも描き直されませんでした(0回)。今回の構成では、<Header /> の要素は App の中で作られ、Provider には children として渡されています。state が変わったのは AppProvider の中だけで、Header には App で作られた同じ要素が children として渡され続けます。そのため描き直されなかったと考えられます。

Context を分けられないときの書き方

1つの Context のまま、描き直しが重い部分だけを描き直さずに済ませる方法もあります。memo のリファレンスには、コンポーネントを2つに分け、外側で Context を読み、必要な値だけを memo で包んだ内側に props で渡す方法が書かれています。Provider は、useMemo で包んだ版と同じです。

tsx
// 外側は Context を読んで、必要な値だけを props で渡す
function ThemeLabel() {
  countRender('ThemeLabel');
  const { theme } = useApp();
  return <ThemeLabelView theme={theme} />;
}

// 内側は memo で包み、props が同じなら描画を飛ばす
const ThemeLabelView = memo(function ThemeLabelView({ theme }: { theme: string }) {
  countRender('ThemeLabelView');
  return <p id="theme">テーマ: {theme}</p>;
});
text
$ node run.mjs ./out/AppMemoChild.mjs
(前略)
  +1 を3回押したあと: 「テーマ: light」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 3 / ThemeLabelView 0 / CountLabel 3 / IncrementButton 3 / ThemeButton 3
  テーマ切り替えを1回押したあと: 「テーマ: dark」「カウント: 3」
    描画回数: Header 0 / ThemeLabel 1 / ThemeLabelView 1 / CountLabel 1 / IncrementButton 1 / ThemeButton 1

外側の ThemeLabel は3回描き直されましたが、内側の ThemeLabelView は0回でした。テーマを切り替えたときは、内側も1回描き直されています。外側は Context を読むだけの軽いコンポーネントにしておき、重い描画を内側に置けば、Context を分けなくても重い部分の描き直しは避けられます。

どの書き方を選ぶか

今回の結果をまとめると、次のとおりです。

書き方 カウントが変わったとき、テーマだけを読むコンポーネントは
1つの Context に全部入れる 描き直される
1つの Context + useMemo 描き直される
1つの Context + 読む側を memo 描き直される
1つの Context + 外側と memo した内側に分ける 外側だけ描き直され、内側は飛ばされる
Context を分ける 描き直されない

よく変わる値と、あまり変わらない値が同じ Context に入っているなら、Context を分けるのが効果的です。更新する関数だけを読むボタンのようなコンポーネントも、関数を別の Context に分ければ描き直されなくなります。分けた Context にオブジェクトや関数を入れる場合は、useMemo や useCallback で包んでおきます。そうしないと、Provider が描き直されるたびに新しい値になってしまうからです。

ただし、useContext のリファレンスには、Provider が描き直されるたびに新しいオブジェクトを渡してしまう場合について、小さなアプリでは問題にならないとも書かれています。Context を分けると Provider と型が増えるので、描き直しが実際に重い場面から手を付けるのがよいと筆者は考えます。

補足

今回数えたのは、コンポーネントの関数が呼ばれた回数です。画面の表示にかかる時間は測っていません。React Compiler を使ったときの描画回数も確かめていません。

​Posted by 86