- React
React再レンダリングの原因と確認方法|childrenで防ぐ書き方
Reactの再レンダリングとは、コンポーネントの関数がもう一度呼ばれ、新しいJSXが作られることです。 きっかけは「自分のstateが変わった」「親が再レンダーされた」「読んでいるContextの値が変わった」の3つが基本で、これにkeyの変更による作り直しと、開発時のStrictModeによる二重実行が加わります。
この記事では、5つの原因をそれぞれ最小のコードで再現し、どの部品が何回レンダーされたかのログを載せました。ブラウザの拡張機能がなくても確認できるよう、<Profiler onRender> でphaseと時間をコード上で記録する方法も示します。さらに、childrenとして渡すと再レンダーされない理由を要素の === 比較で確かめ、memoが効かなくなる書き方と、React Compilerを有効にしたときの回数の違いまで実行結果つきで比べます。
確認環境:Ubuntu 24.04 / Node.js 22.22.2 / react 19.3.0・react-dom 19.3.0 / jsdom 30.1.1 / babel-plugin-react-compiler 1.0.0(@babel/core 8.0.6、@babel/preset-react 8.0.1)。jsdom上で開発用ビルドを動かして計測しています。
React再レンダリングの原因と防ぎ方の早見表
| 原因 | 何が起きるか | 今回のログ | 防ぐ・減らす書き方 |
|---|---|---|---|
| 自分のstateが変わる | その部品と、返すJSXに書いた子が再レンダー | +1で Counter×1 |
stateを使う部品を小さく切り出す |
| 親が再レンダーされる | propsが同じでも子の関数が呼ばれる | Parent×1 Child×1 |
childrenで渡す/memoで包む |
| 読んでいるContextの値が変わる | memoしていても読んでいる部品は再レンダー | ThemeLabel×1(memo済み) |
Contextを分ける/値の参照を安定させる |
| keyが変わる | 再レンダーではなく作り直し(stateも初期化) | cleanup → effect が出る | 意図したリセットにだけkeyを変える |
| StrictMode(開発時のみ) | 関数本体とEffectが1回余分に実行される | Strict×2 |
対策は不要。本番ビルドでは1回 |
| 確認方法 | わかること | 使いどころ |
|---|---|---|
| 関数の1行目で回数を数える | どの部品が何回呼ばれたか | 最小コードでの切り分け、テスト |
<Profiler onRender> |
phase(mount / update)とかかった時間 | 画面の一部を数値で継続的に記録したいとき |
| React DevToolsのProfiler | 画面操作ごとの再レンダー箇所を図で確認 | ブラウザで全体を眺めたいとき |
先に答えをまとめると、「stateを持つ部品に、重い部品をchildrenとして渡す」だけでmemoなしに再レンダーを防げます。 以下、順に実行結果で確かめます。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React再レンダリングの原因と確認方法|childrenで防ぐ書き方」
計測の準備をする
mkdir rerender-demo && cd rerender-demo
npm init -y
npm i react react-dom jsdom
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を通します。
// 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}/`);
次のコードを src/rr.jsx の先頭に置きます。画面を描画し、指定したボタンを順に押して、そのたびに回数を出力する関数です。以降の各節のコードをこの下に順に貼り足していくと、1つのファイルとして動きます。
import { log, take } from './setup.mjs';
import { act, createContext, memo, Profiler, StrictMode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
// 画面を描画し、ボタンを順に押して、そのたびにレンダー回数を出力する
async function run(title, element, steps) {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
console.log(`\n== ${title} ==`);
await act(async () => root.render(element));
console.log('初回 :', take());
for (const [label, id] of steps) {
await act(async () => document.getElementById(id).click());
console.log(`${label} :`, take());
}
await act(async () => root.unmount());
container.remove();
take();
}
node build.mjs && node out/rr.mjs
原因1:自分のstateが変わる
// ===== 1. state の更新 =====
function Counter() {
log('Counter');
const [n, setN] = useState(0);
return (
<>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
<button id="same" onClick={() => setN(n)}>同じ値</button>
</>
);
}
await run('1. state の更新', <Counter />, [['+1', 'inc'], ['同じ値をセット(1回目)', 'same'], ['同じ値をセット(2回目)', 'same']]);
== 1. state の更新 ==
初回 : Counter×1
+1 : Counter×1
同じ値をセット(1回目) : Counter×1
同じ値をセット(2回目) : (なし)
set関数に新しい値を渡すと、その部品が再レンダーされます。 同じ値を渡した場合は、1回目だけ関数が呼ばれ、2回目は呼ばれませんでした。Reactは値が変わっていないと判断できた時点で、子のレンダーと画面の更新を省きます。関数が1回呼ばれることがある点は、回数を数えるときに知っておくと混乱しません。
原因2:親が再レンダーされる
// ===== 2. 親の再レンダー =====
function Child() { log('Child'); return <p>props なしの子</p>; }
const MemoChild = memo(function MemoChild() { log('MemoChild'); return <p>memo した子</p>; });
function Parent() {
log('Parent');
const [n, setN] = useState(0);
return (
<>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
<Child />
<MemoChild />
</>
);
}
await run('2. 親の再レンダー', <Parent />, [['+1', 'inc']]);
== 2. 親の再レンダー ==
初回 : Parent×1 Child×1 MemoChild×1
+1 : Parent×1 Child×1
Childはpropsを1つも受け取っていないのに再レンダーされました。 Reactは「親が再レンダーされたら、返したJSXに書かれた子も再レンダーする」のが既定の動きで、propsが変わったかどうかは見ていません。memoで包んだMemoChildは、propsを前回と比べて同じなら省かれます。「propsが変わると再レンダーされる」という説明をよく見かけますが、正確にはpropsの比較が行われるのはmemoで包んだときだけです。
原因3:読んでいるContextの値が変わる
// ===== 3. Context の値の変更 =====
const ThemeCtx = createContext('light');
const ThemeLabel = memo(function ThemeLabel() { log('ThemeLabel'); return <p>{useContext(ThemeCtx)}</p>; });
const Plain = memo(function Plain() { log('Plain'); return <p>Context を読まない</p>; });
function ThemeApp() {
log('ThemeApp');
const [theme, setTheme] = useState('light');
return (
<ThemeCtx value={theme}>
<button id="dark" onClick={() => setTheme('dark')}>dark</button>
<ThemeLabel />
<Plain />
</ThemeCtx>
);
}
await run('3. Context の値の変更', <ThemeApp />, [['dark に変更', 'dark']]);
== 3. Context の値の変更 ==
初回 : ThemeApp×1 ThemeLabel×1 Plain×1
dark に変更 : ThemeApp×1 ThemeLabel×1
ThemeLabelとPlainはどちらもmemoで包んでいますが、Contextを読んでいるThemeLabelだけが再レンダーされました。 React公式のmemoのページにも、メモ化したコンポーネントでも、自身のstateや使っているContextが変わったときは再レンダーされると書かれています。memoが関わるのは親から渡されるpropsだけです。
原因4:keyが変わる
// ===== 4. key の変更 =====
function Editor({ id }) {
log('Editor');
const [text, setText] = useState(`下書き${id}`);
useEffect(() => {
console.log(` effect: Editor ${id} マウント`);
return () => console.log(` cleanup: Editor ${id} アンマウント`);
}, [id]);
return <input id="editor" value={text} onChange={(e) => setText(e.target.value)} />;
}
function KeyApp() {
log('KeyApp');
const [id, setId] = useState(1);
return (
<>
<button id="next" onClick={() => setId(id + 1)}>次へ</button>
<Editor key={id} id={id} />
</>
);
}
await run('4. key の変更', <KeyApp />, [['key を 1→2', 'next']]);
== 4. key の変更 ==
effect: Editor 1 マウント
初回 : KeyApp×1 Editor×1
cleanup: Editor 1 アンマウント
effect: Editor 2 マウント
key を 1→2 : KeyApp×1 Editor×1
cleanup: Editor 2 アンマウント
回数の表示は Editor×1 で原因2と同じに見えますが、cleanupとeffectが出ている点が違います。 keyが変わると、Reactは前の部品を取り外して新しい部品を作ります。stateも初期値からやり直しです。入力フォームを別の対象に切り替えるときなど、状態をまとめてリセットしたい場面では便利な仕組みです。一方、リストのkeyに毎回変わる値(乱数など)を使うと、全行が毎回作り直されるので、データのIDのように変わらない値を使います。
原因5:StrictModeの二重実行(開発時のみ)
// ===== 5. StrictMode の二重実行 =====
function Strict() {
log('Strict');
const [n, setN] = useState(0);
useEffect(() => {
console.log(' effect 実行');
return () => console.log(' cleanup 実行');
}, []);
return <button id="inc" onClick={() => setN(n + 1)}>{n}</button>;
}
await run('5-a. StrictMode なし', <Strict />, [['+1', 'inc']]);
await run('5-b. StrictMode あり', <StrictMode><Strict /></StrictMode>, [['+1', 'inc']]);
== 5-a. StrictMode なし ==
effect 実行
初回 : Strict×1
+1 : Strict×1
cleanup 実行
== 5-b. StrictMode あり ==
effect 実行
cleanup 実行
effect 実行
初回 : Strict×2
+1 : Strict×2
cleanup 実行
StrictModeで包むと、関数本体が初回も更新時も2回ずつ呼ばれ、Effectは初回に「実行 → cleanup → 実行」と1往復余分に動きました。 React公式のStrictModeのページには、純粋でないレンダーによる不具合を見つけるために余分に再レンダーすること、cleanupの書き漏れを見つけるためにEffectを余分に再実行すること、これらのチェックは開発時だけで本番ビルドには影響しないことが書かれています。
回数を数えていて「全部2倍になっている」ときは、StrictModeが理由です。計測の間だけ外すか、2で割って読みます。
確認方法:Profilerのコールバックで記録する
<Profiler> で囲むと、その範囲がコミットされるたびに onRender が呼ばれます。React公式ドキュメントによる引数の意味は次のとおりです。
| 引数 | 内容 |
|---|---|
id |
<Profiler> に付けたid。複数置いたときの区別に使う |
phase |
"mount"(初回)、"update"(再レンダー)、"nested-update" |
actualDuration |
今回の更新で、範囲内のレンダーに実際にかかったミリ秒 |
baseDuration |
メモ化なしで範囲全体を再レンダーした場合の見積もりミリ秒 |
startTime |
Reactが今回の更新のレンダーを始めた時刻 |
commitTime |
Reactが今回の更新をコミットした時刻 |
// ===== 6. Profiler の onRender で記録する =====
function onRender(id, phase, actualDuration, baseDuration) {
console.log(` [Profiler] id=${id} phase=${phase} actual=${actualDuration.toFixed(2)}ms base=${baseDuration.toFixed(2)}ms`);
}
function Heavy() {
log('Heavy');
let x = 0;
for (let i = 0; i < 3_000_000; i++) x += i; // わざと時間をかける
return <p>{x}</p>;
}
const MemoHeavy = memo(Heavy);
function ProfApp({ Target }) {
log('ProfApp');
const [n, setN] = useState(0);
return (
<Profiler id="list" onRender={onRender}>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
<Target />
</Profiler>
);
}
await run('6-a. Profiler(memo なし)', <ProfApp Target={Heavy} />, [['+1', 'inc']]);
await run('6-b. Profiler(memo あり)', <ProfApp Target={MemoHeavy} />, [['+1', 'inc']]);
== 6-a. Profiler(memo なし) ==
[Profiler] id=list phase=mount actual=8.35ms base=7.99ms
初回 : ProfApp×1 Heavy×1
[Profiler] id=list phase=update actual=2.03ms base=2.02ms
+1 : ProfApp×1 Heavy×1
== 6-b. Profiler(memo あり) ==
[Profiler] id=list phase=mount actual=2.27ms base=2.06ms
初回 : ProfApp×1 Heavy×1
[Profiler] id=list phase=update actual=0.02ms base=2.05ms
+1 : ProfApp×1
memoありの更新では、actualが0.02msまで下がり、baseは約2msのままです。公式ドキュメントにも、actualDurationは初回マウントの後に大きく下がるのが理想で、baseDurationと比べるとメモ化が働いているかがわかると説明されています。「actualがbaseに近いままなら、まだ省ける再レンダーがある」と読めます。時間の値は実行のたびに少し変わるので、1回の数値ではなく傾向で判断します。
公式ドキュメントには、プロファイリングは余分な処理を伴うため本番ビルドでは既定で無効になっていること、<Profiler> は必要なときだけ使うことも書かれています。React DevToolsのProfilerタブは、同じ情報を画面操作ごとに図で見せてくれる道具なので、ブラウザで動かせるときはそちらも併用できます。
防ぐ書き方:childrenとして渡す
// ===== 7. children として渡す =====
function Slow() { log('Slow'); return <p>重い部品</p>; }
function InlineBox() {
log('InlineBox');
const [n, setN] = useState(0);
return (
<div>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
<Slow />
</div>
);
}
function ChildrenBox({ children }) {
log('ChildrenBox');
const [n, setN] = useState(0);
return (
<div>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
{children}
</div>
);
}
await run('7-a. 中に直接書く', <InlineBox />, [['+1', 'inc']]);
await run('7-b. children で渡す', <ChildrenBox><Slow /></ChildrenBox>, [['+1', 'inc']]);
== 7-a. 中に直接書く ==
初回 : InlineBox×1 Slow×1
+1 : InlineBox×1 Slow×1
== 7-b. children で渡す ==
初回 : ChildrenBox×1 Slow×1
+1 : ChildrenBox×1
同じ見た目でも、Slowを中に直接書くと再レンダーされ、childrenで渡すと再レンダーされません。 memoは使っていません。
理由を要素の参照比較で確かめる
JSXの <Slow /> は、実行すると「typeとpropsを持つただのオブジェクト(React要素)」になります。次のコード(src/eq.jsx)で、前回のレンダーで使った要素と今回の要素が同じ参照かを === で比べます。
import './setup.mjs';
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
function Slow() { return <p>重い部品</p>; }
let prevInline;
function InlineBox() {
const [n, setN] = useState(0);
const el = <Slow />; // レンダーのたびにここで要素を作る
console.log(' 直接書いた要素 前回 === 今回 :', prevInline === el);
prevInline = el;
return <div><button id="inc" onClick={() => setN(n + 1)}>{n}</button>{el}</div>;
}
let prevChildren;
function ChildrenBox({ children }) {
const [n, setN] = useState(0);
console.log(' children 前回 === 今回 :', prevChildren === children);
prevChildren = children;
return <div><button id="inc" onClick={() => setN(n + 1)}>{n}</button>{children}</div>;
}
for (const [title, element] of [['中に直接書く', <InlineBox />], ['children で渡す', <ChildrenBox><Slow /></ChildrenBox>]]) {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
console.log(`== ${title} ==`);
await act(async () => root.render(element));
await act(async () => document.getElementById('inc').click());
await act(async () => document.getElementById('inc').click());
await act(async () => root.unmount());
container.remove();
}
// 要素はただのオブジェクト。同じ JSX でも作るたびに別の参照になる
const a = <Slow />;
const b = <Slow />;
console.log('== 要素の比較 ==');
console.log(' a === b :', a === b, '| a === a :', a === a);
console.log(' a.type === b.type :', a.type === b.type, '| typeof a :', typeof a);
node build.mjs && node out/eq.mjs
== 中に直接書く ==
直接書いた要素 前回 === 今回 : false
直接書いた要素 前回 === 今回 : false
直接書いた要素 前回 === 今回 : false
== children で渡す ==
children 前回 === 今回 : false
children 前回 === 今回 : true
children 前回 === 今回 : true
== 要素の比較 ==
a === b : false | a === a : true
a.type === b.type : true | typeof a : object
(各1行目は、比べる相手がまだない初回レンダーなのでfalseです。)
- 中に直接書いた場合:InlineBoxの関数が呼ばれるたびに
<Slow />が実行され、新しいオブジェクトが作られます。前回と別の参照なので、ReactはSlowを再レンダーします。 - childrenで渡した場合:
<Slow />を作ったのはChildrenBoxの外側です。ChildrenBoxのstateが変わっても外側は再レンダーされないので、childrenには前回とまったく同じ参照が入っています。Reactは前回と同じ要素を受け取ると、その先のレンダーを省きます。
React公式のmemoのページでも、他のコンポーネントを見た目の上で包むコンポーネントはJSXをchildrenとして受け取るようにすると、包む側が自身のstateを更新しても子の再レンダーが不要だとReactが判断できる、と説明されています。
childrenが向く形・向かない形
| 形 | childrenで渡す効果 |
|---|---|
| stateを持つ枠(開閉、スクロール位置、タブ)の中に重い内容がある | 大きい。枠だけが再レンダーされる |
| 内容がそのstateを使って表示を変える | 使えない。stateを使う部分だけ別の部品に切り出す |
| 外側の部品も頻繁に再レンダーされる | 外側が動くと要素が作り直されるので、memoと組み合わせる |
children以外のprops(header={<Header />} など)でも仕組みは同じです。
memoが効かなくなる書き方と直し方
memoは、すべてのpropsを前回の値と Object.is で比べます。オブジェクトや関数をJSXの中に直接書くと、レンダーのたびに新しい参照になるため、比較が毎回「変わった」になります。
// ===== 8. memo とインラインの props =====
const Row = memo(function Row({ label, style, onPick }) {
log('Row');
return <li style={style} onClick={onPick}>{label}</li>;
});
function ListInline() {
log('List');
const [n, setN] = useState(0);
return (
<ul>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
<Row label="A" style={{ color: 'red' }} onPick={() => console.log('pick')} />
</ul>
);
}
function ListStable() {
log('List');
const [n, setN] = useState(0);
const style = useMemo(() => ({ color: 'red' }), []);
const onPick = useCallback(() => console.log('pick'), []);
return (
<ul>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
<Row label="A" style={style} onPick={onPick} />
</ul>
);
}
function ListPrimitive() {
log('List');
const [n, setN] = useState(0);
return (
<ul>
<button id="inc" onClick={() => setN(n + 1)}>{n}</button>
<Row label="A" />
</ul>
);
}
await run('8-a. memo + インラインの object / 関数', <ListInline />, [['+1', 'inc']]);
await run('8-b. memo + useMemo / useCallback', <ListStable />, [['+1', 'inc']]);
await run('8-c. memo + 文字列だけ', <ListPrimitive />, [['+1', 'inc']]);
== 8-a. memo + インラインの object / 関数 ==
初回 : List×1 Row×1
+1 : List×1 Row×1
== 8-b. memo + useMemo / useCallback ==
初回 : List×1 Row×1
+1 : List×1
== 8-c. memo + 文字列だけ ==
初回 : List×1 Row×1
+1 : List×1
| 書き方 | Rowの再レンダー | 理由 |
|---|---|---|
8-a. style={{...}} と onPick={() => ...} を直接書く |
される | 毎回新しいオブジェクトと関数が渡る |
| 8-b. useMemo / useCallbackで包む | されない | 参照が前回と同じ |
| 8-c. 文字列や数値だけ渡す | されない | プリミティブは値で比べられる |
公式ドキュメントでも、memoを活かすにはpropsの変化を減らすこと、オブジェクトはuseMemo、関数はコンポーネントの外で宣言するかuseCallbackを使うことが勧められています。変わらないオブジェクトや関数は、コンポーネントの外に定数として置くのが最も手軽です。
React Compilerを有効にしたときの回数比較
React Compilerは、ビルド時にコンポーネントを解析し、useMemo・useCallback・memoに相当するメモ化を自動で入れるツールです。同じ src/rr.jsx を次のコマンドで変換して実行し、更新時(+1など)の回数を比べました。
node build.mjs compiler && node out-c/rr.mjs
| 節 | 通常ビルド(更新時) | Compiler有効(更新時) | 変化 |
|---|---|---|---|
| 1. stateの更新 | Counter×1 | Counter×1 | 同じ |
| 2. 親の再レンダー | Parent×1 Child×1 | Parent×1 | memoなしのChildが省かれた |
| 3. Contextの値の変更 | ThemeApp×1 ThemeLabel×1 | ThemeApp×1 ThemeLabel×1 | 同じ |
| 4. keyの変更 | KeyApp×1 Editor×1(作り直し) | KeyApp×1 Editor×1(作り直し) | 同じ |
| 5-b. StrictModeあり | Strict×2 | Strict×2 | 同じ |
| 6-a. Profiler(memoなし) | ProfApp×1 Heavy×1 | ProfApp×1 | Heavyが省かれ、actualが0.02msに |
| 7-a. 中に直接書く | InlineBox×1 Slow×1 | InlineBox×1 | childrenで渡した場合と同じに |
| 7-b. childrenで渡す | ChildrenBox×1 | ChildrenBox×1 | 同じ |
| 8-a. インラインのobject / 関数 | List×1 Row×1 | List×1 | 手書きのuseMemo / useCallbackと同じに |
Compiler有効時の出力から、変化のあった節を抜き出します。
== 2. 親の再レンダー ==
初回 : Parent×1 Child×1 MemoChild×1
+1 : Parent×1
== 6-a. Profiler(memo なし) ==
[Profiler] id=list phase=mount actual=4.06ms base=3.77ms
初回 : ProfApp×1 Heavy×1
[Profiler] id=list phase=update actual=0.02ms base=3.76ms
+1 : ProfApp×1
== 7-a. 中に直接書く ==
初回 : InlineBox×1 Slow×1
+1 : InlineBox×1
== 8-a. memo + インラインの object / 関数 ==
初回 : List×1 Row×1
+1 : List×1
「親の再レンダー」「インラインのprops」が原因の再レンダーは、Compilerが自動で省きました。 一方、stateの更新・Contextの値の変更・keyの変更・StrictModeの二重実行は、どれも値が実際に変わった(または開発時の検査として行われる)再レンダーなので、Compilerを有効にしても回数は同じです。Compilerを使う場合でも、「stateをどこに置くか」「Contextをどう分けるか」という設計は引き続き自分で決めます。
再レンダリングを調べる手順
| 手順 | やること | 見るポイント |
|---|---|---|
| 1 | 気になる範囲を <Profiler> で囲む(またはDevToolsで記録する) |
actualがbaseに近い更新を探す |
| 2 | その範囲の部品の1行目で回数を数える | 操作と関係のない部品が呼ばれていないか |
| 3 | 原因を早見表に当てはめる | state/親/Context/key/StrictModeのどれか |
| 4 | stateを使う部品を小さくする、またはchildrenで渡す | memoを書く前に構成で解決できないか |
| 5 | 残った箇所にmemoを付け、propsの参照を安定させる | オブジェクトと関数を直接書いていないか |
| 6 | もう一度計測する | 回数とactualが下がったか |
再レンダーそのものは、Reactが画面を正しく保つための通常の動作です。回数を0にすることが目的ではなく、操作に対して重いと感じる箇所を数値で見つけ、そこだけに手を入れるのが効率のよい進め方です。
よくある質問
Q. propsが変わっていないのに子が再レンダーされるのはなぜですか? A. 親が再レンダーされると、返したJSXに書かれた子は既定で再レンダーされるためです。propsの比較が行われるのはmemoで包んだ場合だけです(原因2の実行結果)。
Q. console.logが2回ずつ出ます。不具合でしょうか? A. StrictModeで包んでいる開発環境では、関数本体が2回呼ばれます(原因5)。公式ドキュメントに開発時だけの動作と書かれており、本番ビルドには影響しません。
Q. childrenとmemoはどちらを先に検討するとよいですか? A. childrenを先に検討します。構成を変えるだけで、比較の処理もpropsの参照管理も不要になります。外側も頻繁に再レンダーされる場合や、リストの行のように数が多い部品にはmemoを組み合わせます。
Q. React Compilerを入れればmemoやuseCallbackは書かなくてよいですか? A. 今回の計測では、手書きのメモ化と同じ回数になりました。公式ドキュメントでは、useMemoとuseCallbackはメモ化を細かく制御したい場合の手段として引き続き使えると案内されています。
まとめ
- 再レンダリングの原因は、stateの更新・親の再レンダー・Contextの値の変更の3つが基本。keyの変更は作り直し、StrictModeは開発時だけの二重実行です。
- propsが同じでも、親が再レンダーされれば子は再レンダーされる。propsを比べるのはmemoで包んだときだけです。
<Profiler onRender>でphaseとactualDurationを記録すると、ブラウザなしでも再レンダーを数値で確認できます。- childrenで渡した要素は前回と同じ参照(===がtrue)なので、包む側のstateが変わっても再レンダーされません。
- memoは、インラインのオブジェクトや関数をpropsに渡すと効かなくなる。useMemo・useCallbackか、コンポーネント外の定数にします。
- React Compilerは親由来とインラインprops由来の再レンダーを自動で省く。state・Context・key・StrictMode由来の回数は変わりません。
参考・出典
- React公式ドキュメント「Profiler」 https://react.dev/reference/react/Profiler
- React公式ドキュメント「StrictMode」 https://react.dev/reference/react/StrictMode
- React公式ドキュメント「memo」 https://react.dev/reference/react/memo
- React公式ドキュメント「React Compiler: Introduction」 https://react.dev/learn/react-compiler/introduction
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React再レンダリングの原因と確認方法|childrenで防ぐ書き方」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。