setCount(count + 1) と書けば画面の数字が変わる。React を使い始めると当たり前のように受け入れてしまうこの動きですが、その裏では React が決まった順番で3つの仕事をこなしています。「トリガー」「レンダー」「コミット」の3ステップです。この流れを知らないまま開発を進めると、「なぜコンポーネント関数が2回呼ばれるのか」「再レンダーされたのに入力中の文字が消えないのはなぜか」といった疑問につまずきます。この記事では、React 公式ドキュメントの「レンダーとコミット」の考え方に沿って、state を更新してから画面が書き換わるまでに何が起きているのかを順番に解説します。
目次
画面が更新されるまでの3ステップ
React 公式ドキュメントでは、この一連の流れをレストランの厨房にたとえています。お客さん(コンポーネント)から注文を受け、厨房で料理を作り、テーブルに運ぶ、という3段階です。React が担当するのはこの3つで、それぞれ役割がはっきり分かれています。
| ステップ | やること |
|---|---|
| 1. トリガー(Trigger) | レンダーのきっかけが発生する。初回の描画か、state の更新のどちらか |
| 2. レンダー(Render) | React がコンポーネント関数を呼び出し、何を表示すべきかを計算する。この時点では DOM に触らない |
| 3. コミット(Commit) | 計算結果を実際の DOM に反映する。前回と差分がある部分だけを書き換える |
重要なのは、レンダーとコミットが別のステップであるという点です。日本語で「レンダリング」と言うと画面に描画されることまで含めて考えがちですが、React 用語のレンダーは「コンポーネント関数を呼んで JSX を受け取ること」だけを指します。DOM が書き換わるのはその次のコミットの段階です。この記事でつまずきやすいポイントのほとんどは、この区別が付けば理解できます。
ステップ1: トリガー — レンダーが始まるきっかけ
レンダーが始まる理由は2つしかありません。アプリの初回レンダーと、state の更新(およびその親からの再レンダー)です。
初回レンダーはアプリの起動時に一度だけ
アプリが最初に画面に現れるときのトリガーは、createRoot() で作ったルートに対する render() の呼び出しです。React で作られたアプリのエントリーポイントには、必ずこの2行があります。
import { createRoot } from 'react-dom/client';
import App from './App';
// index.html の <div id="root"></div> を React の管理下に置く
const root = createRoot(document.getElementById('root')!);
// この render() がアプリ全体の初回レンダーのトリガーになる
root.render(<App />);
Next.js や Vite のテンプレートを使っている場合、この記述はフレームワーク側に隠れていることもありますが、内部で行われていることは同じです。render() が呼ばれた瞬間に、React はステップ2のレンダーへ進みます。
2回目以降は state の更新がトリガーになる
画面が表示されたあとは、useState が返すセッター関数(setCount など)を呼ぶことが唯一のトリガーになります。state を更新すると、React はそのコンポーネントを「更新待ち」としてキューに入れ、あらためてレンダーを始めます。
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// ここがレンダーのトリガー。state を更新して再レンダーを予約する
setCount(count + 1);
};
return (
<button type="button" onClick={handleClick}>
クリック回数: {count}
</button>
);
}
逆に言えば、ローカル変数を書き換えても画面は更新されません。let count = 0; を用意して count++ しても、React には「何かが変わった」ことが伝わらないため、トリガーが発生せずレンダーも起きないのです。画面に反映したい値は state として持つ、というルールはここに由来します。
なお、セッター関数を呼んでも新しい値が現在の値と同じ(Object.is で等しい)場合、React は再レンダーとその先の処理をスキップすることがあります。無駄な更新を避けるための最適化です。
ステップ2: レンダー — React がコンポーネント関数を呼び出す
トリガーが起きると、React はコンポーネントを呼び出して「画面に何を表示すべきか」を計算します。この「レンダー」という言葉の正体は、拍子抜けするほど単純です。React があなたの書いたコンポーネント関数を実際に呼び出すこと、それがレンダーです。
初回はルートから、更新時は変わったコンポーネントから
どのコンポーネントから呼び始めるかは、トリガーの種類によって変わります。
| トリガー | React が呼び出すコンポーネント |
|---|---|
| 初回レンダー | render() に渡したルートコンポーネント |
| state の更新 | その state を持っているコンポーネント |
そして呼び出しは再帰的に進みます。呼び出したコンポーネントが返した JSX の中に別のコンポーネントが含まれていれば、React は続けてそのコンポーネントも呼び出します。ネストが深ければ深いところまで、入れ子になったコンポーネントが無くなるまでこれを繰り返し、最終的に「どんな DOM を作るべきか」の完成形を組み立てます。
function Image() {
return <img src="/photo.jpg" alt="写真" />;
}
export default function Gallery() {
return (
<section>
<h1>ギャラリー</h1>
{/* Gallery を呼んだあと、React は続けて Image も呼び出す */}
<Image />
<Image />
<Image />
</section>
);
}
console.log でレンダーの回数を確かめる
「関数が呼ばれている」ことは、コンポーネントの本体に console.log() を1行置くだけで確認できます。JSX を返す return より前、つまり関数の本体に書けば、レンダーのたびに出力されます。
import { useState } from 'react';
function Label({ text }: { text: string }) {
console.log('Label がレンダーされました');
return <p>{text}</p>;
}
export default function Counter() {
const [count, setCount] = useState(0);
// ボタンを押すたびにここが実行される
console.log('Counter がレンダーされました: count =', count);
return (
<div>
<Label text={`クリック回数: ${count}`} />
<button type="button" onClick={() => setCount(count + 1)}>
増やす
</button>
</div>
);
}
ボタンを押すと、コンソールには Counter と Label の両方のログが出ます。count を持っているのは Counter ですが、その子である Label も再帰的に呼び出されるためです。ログの数を数えれば、どのコンポーネントが何回呼ばれているかが一目で分かります。パフォーマンスを調べるときの、もっとも手軽な第一歩です。
ここで「1回しか押していないのにログが2回出る」と戸惑うことがあります。これは開発中に StrictMode が有効になっている場合の挙動で、React が意図的にコンポーネントを2回呼び出し、後述する「純粋さ」が守られているかを検査しているからです。本番ビルドでは1回だけになります。
レンダーは純粋な計算でなければならない
レンダーの段階で React がやっているのは「計算」だけです。だからこそ、コンポーネント関数は純粋(pure)であることが求められます。純粋とは、次の2つを満たすことです。
ひとつは、同じ入力からは必ず同じ結果が返ること。同じ props と state を渡されたら、いつ何度呼び出しても同じ JSX を返す必要があります。もうひとつは、レンダーの前から存在していたものを変更しないこと。関数の外側で宣言した変数やオブジェクト、props で受け取ったオブジェクト、そして DOM そのものを、レンダー中に書き換えてはいけません。
// コンポーネントの外にある変数
let total = 0;
export default function Cart({ items }: { items: number[] }) {
// NG: 外部の変数をレンダー中に書き換えている
items.forEach((price) => {
total += price;
});
// NG: レンダー中に DOM を直接いじっている
document.title = `合計 ${total} 円`;
return <p>合計: {total} 円</p>;
}
このコードは、2回目のレンダーで total が前回の値に足し込まれてしまい、呼ばれるたびに表示が変わります。同じ入力で同じ結果が返らないため、React が想定するタイミングでコンポーネントを呼び出すたびに結果が壊れていきます。正しくは、合計はレンダー中に計算し直すローカル変数として持ち、document.title の書き換えのような外部への働きかけは useEffect に移します。
import { useEffect } from 'react';
export default function Cart({ items }: { items: number[] }) {
// OK: 計算はレンダーのたびにゼロから行う
const total = items.reduce((sum, price) => sum + price, 0);
// OK: DOM への副作用はコミット後に実行する
useEffect(() => {
document.title = `合計 ${total} 円`;
}, [total]);
return <p>合計: {total} 円</p>;
}
この制約は窮屈に見えますが、React が同じコンポーネントをサーバー上で呼び出したり、優先度の低い更新を途中で中断して後からやり直したりできるのは、レンダーが純粋な計算だからです。純粋さを守ることが、そのまま React の最適化を受けられる条件になっています。
ステップ3: コミット — 計算結果を DOM に反映する
すべてのコンポーネントを呼び出し終えて「表示すべき内容」が確定すると、React はようやく DOM を書き換えます。これがコミットです。ここでのやり方も、初回レンダーと再レンダーで異なります。
| 場面 | コミットで行われること |
|---|---|
| 初回レンダー | appendChild() で必要な DOM ノードをすべて作り、画面に追加する |
| 再レンダー | 前回のレンダー結果と比較し、差分がある部分だけに最小限の操作を行う |
初回はまだ画面に何もないので、React は迷わず全部のノードを作って追加します。問題は再レンダーのほうです。もし毎回すべてを作り直していたら、要素の数が多い画面ではあっという間に重くなります。そこで React は、前回のレンダーで得た結果を覚えておき、今回の結果と突き合わせて、実際に変わったところにだけ DOM 操作を適用します。
ここでもう一段大事なのが、差分がなければ DOM にはいっさい触らないという点です。コンポーネント関数が呼ばれた(=レンダーされた)ことと、DOM が書き換わったことは別だからです。値が前回と同じ要素は、そのまま放置されます。
再レンダーされても入力中の文字が消えない理由
「差分がなければ触らない」がどれほど効いているかは、公式ドキュメントでも紹介されている次の例が分かりやすいです。1秒ごとに現在時刻を更新する時計と、その隣にある入力欄です。
export default function Clock({ time }: { time: string }) {
return (
<>
{/* time が変わるたびに中身が書き換わる */}
<h1>{time}</h1>
{/* こちらは毎回まったく同じ JSX */}
<input />
</>
);
}
import { useState, useEffect } from 'react';
import Clock from './Clock';
export default function App() {
const [time, setTime] = useState(() => new Date().toLocaleTimeString());
useEffect(() => {
// 1秒ごとに state を更新 → 毎秒トリガーが発生する
const id = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(id);
}, []);
return <Clock time={time} />;
}
このアプリでは、1秒ごとに Clock がレンダーされます。ところが入力欄に文字を打ち込んだまま待っていても、入力した文字が消えることはありませんし、カーソル(フォーカス)が外れることもありません。Clock は毎秒呼び出され、そのたびに <input /> を含む JSX を返しているにもかかわらず、です。
理由は、コミットの段階で React が「今回の <input /> は前回の <input /> と何も変わっていない」と判断するからです。属性も子要素も同じなので、DOM を作り直す必要がありません。React が実際に行う操作は <h1> のテキストの差し替えだけで、<input> 要素には指一本触れていないのです。
入力欄に打った文字、フォーカスの位置、スクロール位置、動画の再生位置といった情報は、React ではなく DOM 要素そのものが持っています。DOM 要素が作り直されなければ、それらの状態も当然そのまま残ります。つまり「再レンダーされる=DOM が作り直される」ではないというのが、この仕組みの要点です。
この理解は、パフォーマンスの見方にも直結します。「再レンダーが起きている」こと自体は必ずしも問題ではありません。コンポーネント関数の呼び出しは JavaScript の計算であり、多くの場合は十分に高速です。本当に重くなるのは DOM の書き換えのほうですが、そこは React が差分だけに絞ってくれています。むやみに React.memo で包む前に、まずレンダーの回数ではなく、本当に処理が重い箇所を計測するのが順序です。
逆に、DOM が作り直されて中身が失われるケースもあります。JSX 上の位置が変わったり、要素の種類そのものが変わったりした場合です。この判定の詳しい仕組みは、state の保持とリセットの話につながります。
コミットのあとに起きること
3ステップの最後、React がコミットを終えて DOM が書き換わると、そこからはブラウザーの仕事になります。ブラウザーが更新後の DOM をもとに画面を描き直すことを、React 公式ドキュメントでは「ペイント(painting)」と呼んでいます。DOM を書き換えただけでは、まだユーザーの目に映る画面は変わっていません。
この「ペイント」を基準に、2つの副作用フックの実行タイミングが分かれます。
| フック | 実行タイミング |
|---|---|
useLayoutEffect | コミット直後、ブラウザーがペイントする前に同期的に実行される |
useEffect | ブラウザーがペイントした後に実行される |
要素のサイズや位置を測って、その結果でレイアウトを調整するような処理は、ペイント前に済ませないと一瞬ちらついて見えます。そうした用途では useLayoutEffect を使いますが、ペイントを待たせる分だけ表示が遅れるため、通常の副作用は useEffect を使うのが基本です。
まとめ
React が画面を更新するまでの流れは、トリガー・レンダー・コミットの3ステップです。トリガーは初回の createRoot(...).render(...) と、state の更新の2つだけ。レンダーは React があなたのコンポーネント関数を呼び出すことで、初回はルートから、更新時は state を持つコンポーネントから、入れ子の子へと再帰的に進みます。この段階では DOM に触らず、何を表示すべきかを計算しているだけなので、コンポーネントは同じ入力に同じ JSX を返す純粋な関数でなければなりません。そして最後のコミットで、初回は appendChild() によって DOM を組み立て、再レンダーでは前回との差分がある部分だけを最小限に書き換えます。差分がなければ DOM はいっさい触られないため、毎秒再レンダーされる画面でも入力中の文字やフォーカスが失われることはありません。「再レンダー=DOM の作り直し」ではない、という理解が、React のパフォーマンスを正しく捉える出発点になります。コミット後はブラウザーがペイントを行い、その前に useLayoutEffect、後に useEffect が走る、という位置関係も押さえておくと、副作用の書き分けで迷わなくなります。