React Contextの再レンダリング対策とは、Providerに渡すvalueが変わったときに再レンダーされる部品を、その値を本当に使う部品だけに絞る工夫のことです。 基本は「valueの参照を安定させる」「変わる値と変わらない値でProviderを分ける」「Providerの下の部品をchildrenかmemoで親の再レンダーから切り離す」の3つを組み合わせます。

この記事では、同じ画面を9通りの書き方で作り、ボタンを押したときにどの部品が何回レンダーされたかをログで数えて比較しました。React 19の <Context value> 直書き、条件分岐の後でも呼べる use(Context)、React Compilerを有効にしたときの回数まで、すべて実行結果つきで載せています。コードはそのまま貼って動かせる完成形です。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / react 19.3.0・react-dom 19.3.0 / jsdom 30.1.1 / zustand 5.0.15 / babel-plugin-react-compiler 1.0.0(@babel/core 8.0.6、@babel/preset-react 8.0.1)。ブラウザの代わりにjsdom上で動かし、開発用ビルドで計測しています。

React Context再レンダリング対策の早見表

画面は「countを表示するCountLabel」「+1する関数だけ使うIncButton」「Contextを使わないUnrelated」「3つを並べるLayout」でできています。+1 はContextの値を変える操作、tick はProviderを持つ部品の、Contextと関係のないstateを変える操作です。

書き方 +1 で再レンダーされた部品 tick で再レンダーされた部品 向いている場面
1. 対策なし App、Layout、CountLabel、IncButton、Unrelated 同じ5つ全部 小さな画面。まずはこれで十分
2. valueをuseMemo 同じ5つ全部 同じ5つ全部 単独では変化なし。4の準備
3. memoだけ App、CountLabel、IncButton App、CountLabel、IncButton Unrelatedは止まるが消費側は毎回動く
4. useMemo + memo App、CountLabel、IncButton Appだけ 無関係なstateの影響を切る
5. Provider分割 + memo App、CountLabelだけ Appだけ 更新関数だけ使う部品が多い画面
6. childrenで渡す CounterProvider、CountLabel、IncButton CounterProvider、CountLabel、IncButton memoを書かずにUnrelatedを止める
7. children + Provider分割 CounterProvider、CountLabelだけ CounterProviderだけ useMemoもmemoも書かない構成
8. useSyncExternalStore CountLabelだけ なし 値の一部だけ購読したいとき
9. Zustand CountLabelだけ なし 8をライブラリに任せたいとき

迷ったら「7. childrenで渡す + Provider分割」から始め、値の一部だけを購読したくなったら8か9へ進む、という順番が扱いやすい選び方です。React Compilerを有効にすると1〜4が同じ結果(4と同じ)にそろいますが、IncButtonの再レンダーは残ります(後半で実行結果を示します)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Contextの再レンダリング対策|Provider分割」

Contextで再レンダリングが起きる2つの経路

Contextまわりの再レンダリングは、次の2つの経路が重なって起きます。対策はそれぞれ別なので、まずここを分けて考えると表の結果が読みやすくなります。

経路 きっかけ 再レンダーされる範囲 止める手段
A. 親からの伝播 Providerを書いた部品のstateが変わる その部品が返すJSXに書かれた子孫すべて childrenで渡す/memoで包む
B. Contextの値の変更 Providerのvalueが前回と別の値になる そのContextを読んでいる部品すべて valueの参照を安定させる/Providerを分ける/外部ストア

経路Bについて、React公式ドキュメントのuseContextのページには、Providerが前回と異なるvalueを受け取ると、そのContextを使う子がすべて自動で再レンダーされること、前後の値は Object.is で比較されること、そしてmemoで再レンダーを省いていても新しいContextの値は子に届くことが書かれています。つまりmemoは経路Aは止められますが、経路Bは止められません。

valueにオブジェクトを直接書くと、Providerを持つ部品がレンダーされるたびに新しいオブジェクトが作られます。中身が同じでも Object.is の結果はfalseになるため、経路Bが毎回動きます。これが「関係のないstateを変えただけなのに消費側が再レンダーされる」理由です。

計測の準備をする

パッケージを入れる

mkdir ctx-demo && cd ctx-demo
npm init -y
npm i react react-dom jsdom zustand
npm i @babel/core @babel/preset-react babel-plugin-react-compiler
mkdir src

レンダー回数を数える道具(src/setup.mjs)

各コンポーネントの1行目で log('名前') を呼び、呼ばれた回数をそのままレンダー回数として数えます。

// jsdom でブラウザの代わりを用意し、レンダー回数を数える道具をまとめたファイル
import { JSDOM } from 'jsdom';

const dom = new JSDOM('<!doctype html><html><body></body></html>');
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.IS_REACT_ACT_ENVIRONMENT = true; // act() を使うための宣言

const counts = new Map();

// コンポーネントの本体の1行目で呼ぶ。呼ばれた回数=レンダー回数
export function log(name) {
  counts.set(name, (counts.get(name) ?? 0) + 1);
}

// ここまでの回数を文字列にして返し、カウンターを空に戻す
export function take() {
  const text = [...counts].map(([k, v]) => `${k}×${v}`).join(' ') || '(なし)';
  counts.clear();
  return text;
}

JSXを変換するスクリプト(build.mjs)

引数なしなら通常の変換、compiler を付けるとReact Compilerを通した変換になります。同じソースから2種類の出力を作り、回数を比べます。

// node build.mjs            → out/      (通常のビルド)
// node build.mjs compiler   → out-c/    (React Compiler を有効にしたビルド)
import { transformAsync } from '@babel/core';
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';

const useCompiler = process.argv[2] === 'compiler';
const outDir = useCompiler ? 'out-c' : 'out';
await mkdir(outDir, { recursive: true });

for (const name of await readdir('src')) {
  const code = await readFile(`src/${name}`, 'utf8');
  if (!name.endsWith('.jsx')) { await writeFile(`${outDir}/${name}`, code); continue; }
  const result = await transformAsync(code, {
    filename: name,
    babelrc: false,
    configFile: false,
    plugins: useCompiler ? ['babel-plugin-react-compiler'] : [],
    presets: [['@babel/preset-react', { runtime: 'automatic', development: true }]],
  });
  await writeFile(`${outDir}/${name.replace(/\.jsx$/, '.mjs')}`, result.code);
}
console.log(`built → ${outDir}/`);

実行は次の2行です。

node build.mjs && node out/ctx.mjs
node build.mjs compiler && node out-c/ctx.mjs

9パターンのコードと実行結果

以下のコードを上から順に1つのファイル src/ctx.jsx に貼り合わせると、そのまま動きます。各パターンの直後に、通常ビルドでの出力を載せています。

共通部分

import { log, take } from './setup.mjs';
import { act, createContext, memo, useCallback, useContext, useMemo, useState, useSyncExternalStore } from 'react';
import { createRoot } from 'react-dom/client';
import { create } from 'zustand';

// ---- 画面の部品(全パターン共通の形)----
// CountLabel : count を読む   IncButton : 更新関数だけ使う   Unrelated : Context を使わない

1. 対策なし:valueにオブジェクトを直接書く

// ===== 1. 対策なし =====
const Ctx1 = createContext(null);
function Label1() { log('CountLabel'); return <p>{useContext(Ctx1).count}</p>; }
function Button1() { log('IncButton'); return <button id="inc" onClick={useContext(Ctx1).inc}>+1</button>; }
function Unrelated() { log('Unrelated'); return <p>Context を使わない部品</p>; }
function Layout1() { log('Layout'); return <><Label1 /><Button1 /><Unrelated /></>; }
function App1() {
  log('App');
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);
  return (
    <Ctx1 value={{ count, inc: () => setCount((c) => c + 1) }}>
      <button id="tick" onClick={() => setTick(tick + 1)}>tick {tick}</button>
      <Layout1 />
    </Ctx1>
  );
}
== 1. 対策なし ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1 | 表示 1
tick    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1

+1でもtickでも5つ全部が再レンダーされます。 Appのstateが変わるとAppが返すJSX全体が作り直され、Layout以下が経路Aで再レンダーされるためです。Contextを使わないUnrelatedも含まれます。

2. valueをuseMemoにする

// ===== 2. value を useMemo にするだけ =====
function App2() {
  log('App');
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);
  const inc = useCallback(() => setCount((c) => c + 1), []);
  const value = useMemo(() => ({ count, inc }), [count, inc]);
  return (
    <Ctx1 value={value}>
      <button id="tick" onClick={() => setTick(tick + 1)}>tick {tick}</button>
      <Layout1 />
    </Ctx1>
  );
}
== 2. value を useMemo ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1 | 表示 1
tick    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1

結果は1と同じです。 valueの参照は安定しましたが、Layout以下は経路A(親からの伝播)で再レンダーされ続けています。useMemoは経路Bへの対策なので、経路Aを止める手段と組み合わせて初めて効果が見えます。

3. memoだけを付ける

// ===== 3. memo だけ(value は毎回新しいオブジェクト)=====
const MemoLayout1 = memo(Layout1);
function App3() {
  log('App');
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);
  return (
    <Ctx1 value={{ count, inc: () => setCount((c) => c + 1) }}>
      <button id="tick" onClick={() => setTick(tick + 1)}>tick {tick}</button>
      <MemoLayout1 />
    </Ctx1>
  );
}
== 3. memo だけ ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : App×1 CountLabel×1 IncButton×1 | 表示 1
tick    : App×1 CountLabel×1 IncButton×1

LayoutとUnrelatedは止まりました。一方でtickを押しただけでもCountLabelとIncButtonが再レンダーされています。Appがレンダーされるたびにvalueのオブジェクトが新しくなり、経路Bが動くためです。公式ドキュメントの「memoで省いても新しいContextの値は届く」という説明どおりの結果です。

4. useMemoとmemoを組み合わせる

// ===== 4. useMemo + memo =====
function App4() {
  log('App');
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);
  const inc = useCallback(() => setCount((c) => c + 1), []);
  const value = useMemo(() => ({ count, inc }), [count, inc]);
  return (
    <Ctx1 value={value}>
      <button id="tick" onClick={() => setTick(tick + 1)}>tick {tick}</button>
      <MemoLayout1 />
    </Ctx1>
  );
}
== 4. useMemo + memo ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : App×1 CountLabel×1 IncButton×1 | 表示 1
tick    : App×1

tickではAppだけになりました。 2と3は単独では半分ずつしか効かず、2つそろって経路AとBの両方が止まります。ただし+1ではIncButtonも再レンダーされます。IncButtonが使うのは inc だけですが、同じオブジェクトに count が入っているので、オブジェクトごと新しくなるためです。

5. stateと更新関数でProviderを分ける

// ===== 5. state と dispatch で Provider を分割 + memo =====
const CountCtx = createContext(0);
const IncCtx = createContext(() => {});
function Label5() { log('CountLabel'); return <p>{useContext(CountCtx)}</p>; }
function Button5() { log('IncButton'); return <button id="inc" onClick={useContext(IncCtx)}>+1</button>; }
function Layout5() { log('Layout'); return <><Label5 /><Button5 /><Unrelated /></>; }
const MemoLayout5 = memo(Layout5);
function App5() {
  log('App');
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);
  const inc = useCallback(() => setCount((c) => c + 1), []);
  return (
    <IncCtx value={inc}>
      <CountCtx value={count}>
        <button id="tick" onClick={() => setTick(tick + 1)}>tick {tick}</button>
        <MemoLayout5 />
      </CountCtx>
    </IncCtx>
  );
}
== 5. Provider 分割 + memo ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : App×1 CountLabel×1 | 表示 1
tick    : App×1

+1で再レンダーされる消費側はCountLabelだけになりました。 inc はuseCallbackで参照が変わらないため、IncCtxのvalueは最初から最後まで同じ値です。IncButtonは値が変わらないContextだけを読んでいるので、再レンダーの対象から外れます。useReducerを使う場合も同じ考え方で、stateのContextとdispatchのContextを分けます(dispatchは参照が変わらない関数です)。

6. childrenで渡す

// ===== 6. children で渡す(Provider 用コンポーネントに state を閉じ込める)=====
function CounterProvider6({ children }) {
  log('CounterProvider');
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);
  return (
    <Ctx1 value={{ count, inc: () => setCount((c) => c + 1) }}>
      <button id="tick" onClick={() => setTick(tick + 1)}>tick {tick}</button>
      {children}
    </Ctx1>
  );
}
function App6() { log('App'); return <CounterProvider6><Layout1 /></CounterProvider6>; }
== 6. children で渡す ==
初回    : App×1 CounterProvider×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : CounterProvider×1 CountLabel×1 IncButton×1 | 表示 1
tick    : CounterProvider×1 CountLabel×1 IncButton×1

stateをProvider用の部品に閉じ込め、Layoutをchildrenとして受け取る形です。memoを書いていないのにLayoutとUnrelatedが止まり、Appも再レンダーされません。 childrenの中身(Layoutの要素)はAppが作ったもので、CounterProviderが再レンダーされても同じ参照のまま渡されます。Reactは前回と同じ要素を受け取ると、その先のレンダーを省きます。valueは毎回新しいオブジェクトなので、3と同じくtickでも消費側は動きます。

7. childrenで渡す + Provider分割

// ===== 7. children で渡す + Provider 分割(memo なし)=====
function CounterProvider7({ children }) {
  log('CounterProvider');
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);
  const inc = useCallback(() => setCount((c) => c + 1), []);
  return (
    <IncCtx value={inc}>
      <CountCtx value={count}>
        <button id="tick" onClick={() => setTick(tick + 1)}>tick {tick}</button>
        {children}
      </CountCtx>
    </IncCtx>
  );
}
function App7() { log('App'); return <CounterProvider7><Layout5 /></CounterProvider7>; }
== 7. children + Provider 分割 ==
初回    : App×1 CounterProvider×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : CounterProvider×1 CountLabel×1 | 表示 1
tick    : CounterProvider×1

useMemoもmemoも使わずに、5と同じ絞り込みができました。 書くのはuseCallbackが1つだけです。countは数値なので、同じ値なら Object.is がtrueになり、オブジェクトで包む必要がありません。Providerを分ける利点は、valueをプリミティブや安定した関数のまま渡せる点にもあります。

8. useSyncExternalStoreでReactの外にストアを置く

// ===== 8. useSyncExternalStore で React の外にストアを置く =====
let state = { count: 0, tick: 0 };
const listeners = new Set();
const store = {
  subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
  get: () => state,
  set(patch) { state = { ...state, ...patch }; listeners.forEach((fn) => fn()); },
};
const useStore = (selector) => useSyncExternalStore(store.subscribe, () => selector(store.get()));
const inc8 = () => store.set({ count: store.get().count + 1 });
function Label8() { log('CountLabel'); return <p>{useStore((s) => s.count)}</p>; }
function Button8() { log('IncButton'); return <button id="inc" onClick={inc8}>+1</button>; }
function Layout8() { log('Layout'); return <><Label8 /><Button8 /><Unrelated /></>; }
function App8() {
  log('App');
  return (
    <>
      <button id="tick" onClick={() => store.set({ tick: store.get().tick + 1 })}>tick</button>
      <Layout8 />
    </>
  );
}
== 8. useSyncExternalStore ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : CountLabel×1 | 表示 1
tick    : (なし)

+1ではCountLabelだけ、tickでは誰も再レンダーされません。 状態をReactの外に置き、各部品がセレクター(必要な部分だけ取り出す関数)で購読する形です。Contextは「値のどの部分を使うか」を指定できませんが、この形ならtickだけが変わったときにcountを読む部品は動きません。セレクターが毎回新しいオブジェクトを返すと変化ありと判定され続けるので、プリミティブか保存済みの参照を返すようにします。

9. 外部ストアのライブラリ(Zustand)に任せる

// ===== 9. 外部ストアのライブラリ(Zustand)=====
const useCounter = create((set) => ({
  count: 0,
  tick: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
  addTick: () => set((s) => ({ tick: s.tick + 1 })),
}));
function Label9() { log('CountLabel'); return <p>{useCounter((s) => s.count)}</p>; }
function Button9() { log('IncButton'); return <button id="inc" onClick={useCounter((s) => s.inc)}>+1</button>; }
function Layout9() { log('Layout'); return <><Label9 /><Button9 /><Unrelated /></>; }
function App9() {
  log('App');
  const addTick = useCounter((s) => s.addTick);
  return (
    <>
      <button id="tick" onClick={addTick}>tick</button>
      <Layout9 />
    </>
  );
}
== 9. Zustand ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : CountLabel×1 | 表示 1
tick    : (なし)

結果は8と同じです。購読の仕組みを自分で書かずに済み、更新関数もストアに置けます。Providerで包む必要がない点も8と共通です。

計測部分

ファイルの最後に置きます。初回レンダーの後、+1ボタン、tickボタンの順に押し、そのたびに回数を出力してカウンターを空に戻します。

// ---- 計測 ----
const patterns = [
  ['1. 対策なし', App1],
  ['2. value を useMemo', App2],
  ['3. memo だけ', App3],
  ['4. useMemo + memo', App4],
  ['5. Provider 分割 + memo', App5],
  ['6. children で渡す', App6],
  ['7. children + Provider 分割', App7],
  ['8. useSyncExternalStore', App8],
  ['9. Zustand', App9],
];

for (const [title, App] of patterns) {
  const container = document.createElement('div');
  document.body.append(container);
  const root = createRoot(container);
  await act(async () => root.render(<App />));
  console.log(`\n== ${title} ==`);
  console.log('初回    :', take());
  await act(async () => document.getElementById('inc').click());
  console.log('+1      :', take(), '| 表示', container.querySelector('p').textContent);
  await act(async () => document.getElementById('tick').click());
  console.log('tick    :', take());
  await act(async () => root.unmount());
  container.remove();
}

React 19の書き方:Context直書きとuse

Providerは <Context value> と書ける

ここまでのコードは <Ctx1 value={...}> のように、Contextそのものをタグとして書いています。React公式のcreateContextのページには「React 19からは <SomeContext> をプロバイダーとしてレンダーできる」とあり、それより前のバージョンでは <SomeContext.Provider> を使うと書かれています。

// React 19 以降
<ThemeCtx value={theme}>{children}</ThemeCtx>

// React 18 まで(19 でも動く書き方)
<ThemeCtx.Provider value={theme}>{children}</ThemeCtx.Provider>

上の9パターンはすべてReact 19.3.0で前者の書き方のまま動いています。再レンダーの回数はどちらの書き方でも仕組みが同じです。

useは条件分岐の後でも呼べる

use はReact 19で加わったAPIで、Contextを渡すとuseContextと同じように値を読めます。公式ドキュメントには、useContextと違ってifなどの条件分岐やループの中でも呼べること、呼ぶ場所はコンポーネントかフックの中であることが書かれています。次のコード(src/use.jsx)で、早期returnの後に呼ぶ書き方を2通り比べます。

import { log, take } from './setup.mjs';
import { act, createContext, use, useContext, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { format } from 'node:util';

const ThemeCtx = createContext('light');

// use は if の中でも呼べる
function Badge({ show }) {
  log('Badge');
  if (!show) return <span>非表示</span>;
  const theme = use(ThemeCtx);
  return <span>テーマ: {theme}</span>;
}

// 比較用:useContext を if の中で呼ぶ書き方
function BadgeOld({ show }) {
  log('BadgeOld');
  const [label] = useState('テーマ');
  if (!show) return <span>非表示</span>;
  const theme = useContext(ThemeCtx);
  return <span>{label}: {theme}</span>;
}

function App({ Target }) {
  const [show, setShow] = useState(false);
  const [theme, setTheme] = useState('light');
  return (
    <ThemeCtx value={theme}>
      <button id="show" onClick={() => setShow(true)}>表示</button>
      <button id="dark" onClick={() => setTheme('dark')}>dark</button>
      <Target show={show} />
    </ThemeCtx>
  );
}

async function run(title, Target) {
  const container = document.createElement('div');
  document.body.append(container);
  const root = createRoot(container, {
    onUncaughtError: (e) => console.log('エラー:', e.message.split('\n')[0]),
  });
  const text = () => container.querySelector('span')?.textContent ?? '(表示なし)';
  console.log(`\n== ${title} ==`);
  await act(async () => root.render(<App Target={Target} />));
  console.log('初回       :', text());
  take();
  await act(async () => document.getElementById('dark').click());
  console.log('dark(非表示):', text(), '|', take());
  await act(async () => document.getElementById('show').click());
  console.log('表示       :', text());
  await act(async () => root.unmount());
  container.remove();
}

const origError = console.error;
console.error = (...a) => console.log('console.error:', format(...a).split('\n').slice(0, 8).join('\n'));
await run('use(ThemeCtx) を if の後で呼ぶ', Badge);
await run('useContext(ThemeCtx) を if の後で呼ぶ', BadgeOld);
console.error = origError;
node build.mjs && node out/use.mjs
== use(ThemeCtx) を if の後で呼ぶ ==
初回       : 非表示
dark(非表示): 非表示 | Badge×1
表示       : テーマ: dark

== useContext(ThemeCtx) を if の後で呼ぶ ==
初回       : 非表示
dark(非表示): 非表示 | BadgeOld×1
console.error: React has detected a change in the order of Hooks called by BadgeOld. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks

   Previous render            Next render
   ------------------------------------------------------
1. useState                   useState
2. undefined                  useContext
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

表示       : テーマ: dark

use は早期returnの後に書いても、表示を切り替えたときに警告なしで値を読めました。 同じ位置にuseContextを書くと、表示された瞬間にフックの呼び出し順が変わったという警告(React has detected a change in the order of Hooks)が出ます。値を使わない分岐があるときは use を選ぶと、コードを素直に書けます。

なお use も最も近い「上の」Providerを探します。公式ドキュメントには、use(context) を呼んだコンポーネント自身が返すProviderは対象にならないと書かれているので、Providerと読み取りは別の部品に分けます。

React Compilerを有効にしたときの回数

React Compilerは、ビルド時にコンポーネントやフックを解析し、useMemo・useCallback・memoに相当するメモ化を自動で入れるツールです(公式の紹介ページに、手書きのメモ化と同等の処理を自動で適用するとあります)。同じ src/ctx.jsx を node build.mjs compiler で変換して実行した結果が次の表です。

書き方 通常ビルド +1 Compiler +1 通常ビルド tick Compiler tick
1. 対策なし 5つ全部 App、CountLabel、IncButton 5つ全部 Appだけ
2. valueをuseMemo 5つ全部 App、CountLabel、IncButton 5つ全部 Appだけ
3. memoだけ App、CountLabel、IncButton App、CountLabel、IncButton App、CountLabel、IncButton Appだけ
4. useMemo + memo App、CountLabel、IncButton App、CountLabel、IncButton Appだけ Appだけ
5. Provider分割 + memo App、CountLabel App、CountLabel Appだけ Appだけ
6. childrenで渡す CounterProvider、CountLabel、IncButton CounterProvider、CountLabel、IncButton CounterProvider、CountLabel、IncButton CounterProviderだけ
7. children + Provider分割 CounterProvider、CountLabel CounterProvider、CountLabel CounterProviderだけ CounterProviderだけ
8. useSyncExternalStore CountLabel CountLabel なし なし
9. Zustand CountLabel CountLabel なし なし

Compiler有効時の出力から、1・5の部分を抜き出したものです。

== 1. 対策なし ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : App×1 CountLabel×1 IncButton×1 | 表示 1
tick    : App×1

== 5. Provider 分割 + memo ==
初回    : App×1 Layout×1 CountLabel×1 IncButton×1 Unrelated×1
+1      : App×1 CountLabel×1 | 表示 1
tick    : App×1

読み取れることは2つです。

  • 「対策なし」のコードが、手書きの「useMemo + memo」と同じ結果になりました。 valueのオブジェクトと inc の関数、そして <Layout1 /> の要素がメモ化され、tickではAppだけになります。
  • +1でIncButtonが再レンダーされる点は、Compilerを有効にしても変わりません。 IncButtonは { count, inc } というオブジェクトを読んでおり、countが変わればオブジェクトは新しくなります。これは値が本当に変わった結果の再レンダーなので、メモ化では省けません。止めるには5や7のようにProviderを分けます。

変換後のコードの先頭を見ると、メモ化の様子がわかります。CountLabelにあたる部分は次のように変換されていました。

function Label1() {
  const $ = _c(2);
  log("CountLabel");
  const t0 = useContext(Ctx1);
  let t1;
  if ($[0] !== t0.count) {
    t1 = /*#__PURE__*/_jsxDEV("p", {
      children: t0.count
    }, void 0, false);
    $[0] = t0.count;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  return t1;
}

log("CountLabel") は条件の外にあり、関数そのものは呼ばれていることがわかります。Compilerが省くのは「同じ入力でのJSXの作り直し」と「その先の子のレンダー」で、Contextの値が変わったときに消費側の関数が呼ばれること自体は変わりません。

どの対策を選ぶかの判断表

状況 選ぶ対策 理由
画面が小さく、動きに不満がない 1. 対策なし 再レンダーは速く、書く量が最も少ない
Providerを持つ部品に別のstateがある 6または7(childrenで渡す) memoなしで親からの伝播を切れる
更新関数だけ使う部品が多い 5または7(Provider分割) 値が変わっても関数側の消費者は動かない
React Compilerを導入済み 1の書き方 + Provider分割 メモ化は任せ、分割だけ自分で設計する
大きなオブジェクトの一部だけ読む部品が多い 8または9(外部ストア) セレクターで購読範囲を絞れる
テーマや言語など、めったに変わらない値 1のまま 変更回数が少なく、全体が再レンダーされても影響が小さい

Contextは「めったに変わらない値を遠くへ届ける」用途に向き、頻繁に変わる値は分割するか外部ストアに置く、という分け方を目安にすると選びやすくなります。

つまずきやすい点と直し方

症状 原因 直し方
useMemoを付けたのに回数が減らない 親からの伝播(経路A)が残っている Provider直下をmemoで包むか、childrenで渡す
memoを付けたのに消費側が毎回動く valueが毎回新しいオブジェクト useMemoで包む、またはProviderを分けてプリミティブを渡す
更新関数だけ使う部品が値の変更で動く 値と関数を1つのオブジェクトに入れている 値用と関数用のContextに分ける
useCallbackの関数が毎回変わる 依存配列にstateを入れている setCount((c) => c + 1) の関数形式にして依存を空にする
早期returnの後でContextを読みたい useContextは最上位で呼ぶ決まり use(Context) に置き換える
外部ストアで全部の部品が動く セレクターが毎回新しいオブジェクトを返す プリミティブを1つずつ選ぶ

計測するときは、1行目に log を置く今回の方法のほか、React DevToolsのProfilerでも同じ内容を確認できます。回数を数えてから対策を選ぶと、必要な箇所だけに手を入れられます。

よくある質問

Q. Providerはいくつまで分けてよいですか? A. 数に決まりはありません。「一緒に変わる値は同じContext、別々に変わる値は別のContext」を目安にします。今回の計測では、値用と更新関数用の2つに分けるだけで、更新関数だけ使う部品の再レンダーがなくなりました。

Q. useMemoとmemoはどちらを先に入れるとよいですか? A. 単独ではどちらも効果が半分です(パターン2と3)。入れるなら両方をセットにするか、childrenで渡す形にしてmemoを省きます。React Compilerを有効にしている場合は、どちらも自動で補われました。

Q. React Compilerを入れればContextの対策は不要になりますか? A. valueのメモ化と親からの伝播の遮断は自動になりました。値と更新関数が同じオブジェクトに入っている場合の再レンダーは残るので、Providerの分け方は自分で設計します。

Q. useとuseContextはどちらを使えばよいですか? A. 最上位で呼ぶだけならどちらでも同じ値が読めます。条件分岐やループの中、早期returnの後で読みたいときは use を選びます。

まとめ

  • 再レンダーの経路は2つ:親からの伝播(A)と、Contextの値の変更(B)。対策は別々です。
  • useMemoだけ、memoだけでは回数は半分しか減らない。2つを組み合わせるか、childrenで渡します。
  • stateと更新関数でProviderを分けると、更新関数だけ使う部品は値が変わっても動きません。
  • childrenで渡す + Provider分割なら、useMemoもmemoも書かずに同じ結果になります。
  • 値の一部だけを購読したいときはuseSyncExternalStoreや外部ストア。無関係な変更では誰も再レンダーされませんでした。
  • React 19では <Context value> と use(Context) が使え、use は早期returnの後でも警告なしで読めます。
  • React Compilerはメモ化を自動で入れるが、消費側の再レンダーは残るので、Providerの分割は引き続き有効です。

参考・出典

  • React公式ドキュメント「useContext」 https://react.dev/reference/react/useContext
  • React公式ドキュメント「createContext」 https://react.dev/reference/react/createContext
  • React公式ドキュメント「use」 https://react.dev/reference/react/use
  • React公式ドキュメント「React Compiler: Introduction」 https://react.dev/learn/react-compiler/introduction

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Contextの再レンダリング対策|Provider分割」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?