Menu

Reactのレンダリング:再レンダリングが起きる条件と理由

Reactのコンポーネントは、stateが変わったとき、親がレンダリングされたとき、読んでいるコンテキストが変わったときに再レンダリングされます。レンダリングとコミットの違い、仮想DOMの本当の意味、そして不要なレンダリングを止める方法を学びます。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

Reactのコンポーネントは、自分のstateが変わったとき、親が再レンダリングされたとき、読んでいるコンテキストが変わったときに再レンダリングされます。レンダリングとは、新しいJSXを得るためにReactがコンポーネント関数をもう一度呼ぶことで、その後Reactはページのうち実際に変わった部分だけを更新します。

プレビューの下のコンソールを開いて、ボタンをクリックしてください。propsをまったく受け取らない Title も含め、3つのコンポーネントすべてがログを出します。Title がレンダリングされたのは、親がレンダリングされたからです。

レンダリングのきっかけ

Reactがコンポーネントをレンダリングする理由は、ちょうど次のとおりです。

  1. 最初のレンダリング。アプリが起動したとき、またはコンポーネントがツリーに初めて現れたときです。
  2. stateが変わった。useState のセッターや useReducer のdispatchを新しい値で呼んだときです。
  3. 親がレンダリングされた。デフォルトでは、コンポーネントがレンダリングされると、それが返すすべてのコンポーネントも、一番下までレンダリングされます。
  4. 読んでいるコンテキストが変わった。useContext(SomeContext) を呼ぶコンポーネントは、最も近いプロバイダーが新しい value を渡したときにレンダリングされます(useContextを参照)。

コンポーネントは「propsが変わったから」レンダリングされる、とよく思われていますが、これは原因が逆です。propsは親がレンダリング中に渡す引数なので、新しいpropsが届くのは親がレンダリングされたときだけです。そして親がレンダリングされるだけで十分です。上の Title はpropsがないのに、毎回レンダリングされます。

stateをすでに持っている値(Object.is で比較)にセットしても、子のレンダリングは始まりません。何も変わっていないと気づく前に、Reactがそのコンポーネント1つを1回呼ぶことはありますが、その結果は捨てられます。

レンダリングとコミット

すべての更新は2つのフェーズを経ます。

  • レンダリング。Reactがコンポーネントを呼びます。コンポーネントはJSX、つまり画面に何を表示すべきかを表すただのオブジェクトを返します。ページにはまだ何の変化もありません。だからこそレンダリングは純粋でなければなりません。DOMへの書き込み、リクエスト、コンポーネントの外の変数の変更はしてはいけません。
  • コミット。Reactは新しい出力を前回のものと比べ、その差分をDOMに適用します。変わったノードだけを挿入、削除、更新するのです。その後ブラウザが描画し、それからReactがエフェクトを実行します。

次の例はクリックのたびにレンダリングされますが、Reactは同じ <input> 要素を保ちます。コミットのたびにエフェクトが実行され、それを確認します。

入力欄に何か入力してから、何回かクリックしてください。Reactが入力欄を置き換えないので、入力したテキストは残ります。クリックごとのDOMの変更は、<p> の中の数値だけです。ログは順番も示していて、まず render、コミットの後にエフェクトです。

仮想DOMをわかりやすく

「仮想DOM」は、コンポーネントが返すオブジェクトのよく知られた呼び名です。<p>Items: {count}</p> は、{ type: 'p', props: { children: ['Items: ', 1] } } のようなオブジェクトを作る呼び出しにコンパイルされます。レンダリングの後、Reactはこうしたオブジェクトの新しいツリーを前回のツリーと並べてたどり、型と位置が一致するところでは既存のDOMノードを保ち、変わった属性とテキストだけを更新します。この比較をリコンシリエーション(差分検出)と呼びます。

この用語があいまいな理由は2つあります。Reactは2つ目のDOMのコピーを持って2つのDOMを比べているわけではありません。要素オブジェクトを、自分の内部にあるコンポーネントのツリー(ファイバーツリー)と比べています。また同じ処理が、React Nativeのように、DOMがまったくない描画先も動かしています。Reactのドキュメントはこの言葉をほとんど使わず、代わりにレンダリングとコミットについて語っています。実際に重要なのはその結果です。ほとんどのレンダリングは小さなDOMの更新で終わるか、更新なしで終わるので、レンダリングはDOMの作業に比べて安いのです。

比較の仕組みから、2つのルールが導かれます。同じ場所に違う要素の型が来ると(<div> が <section> に、ComponentA が ComponentB に置き換わると)、古いサブツリーとそのstateは破棄されます。そしてリストでは、key がどの項目がどれかをReactに伝えるので、ノードを作り直さずに移動できます。

不要なレンダリングを止める

余分なレンダリングのほとんどは、気づけるほどのコストになりません。たとえばページの別の場所でのキー入力のたびに大きなリストがレンダリングされるなど、実際に困るときは、次の順番で試してください。

stateを下へ移す

あるstateを画面のごく一部しか使わないなら、そのstateを、その部分だけを包むコンポーネントに置きます。次の版では、キーを押すたびに ProductList が再レンダリングされます。

export default function App() {
    const [text, setText] = useState('');
    return (
        <>
            <input value={text} onChange={(e) => setText(e.target.value)} />
            <ProductList />
        </>
    );
}

入力欄とそのstateを独立したコンポーネントに移せば、リストはもうレンダリングされるコンポーネントの中にありません。

何文字か入力してください。ログを出すのは SearchBox だけです。

代わりにchildrenを渡す

ハイライトを切り替えるパネルのように、stateを重い部分を包むラッパーに置かなければならないこともあります。そのときはラッパーに children を受け取らせます。子の要素は親が作り、ラッパーのstateが変わっても親はレンダリングされないので、それらの要素は前回と同じオブジェクトのままで、Reactはそれを飛ばします。

切り替えボタンをクリックすると、ログを出すのは Highlighter だけです。次に、{children} の代わりに <Article /> を Highlighter のJSXの中に移してみてください。クリックするたびに render Article も出るようになります。

メモ化する

どちらの構造の変更も合わないときは、子を memo で包みます。するとReactはpropsを前回のものと比べ、すべて同じならレンダリングを飛ばします。レンダリング中に作ったオブジェクトや関数は毎回新しいので、memoはたいてい、そうしたpropsのための useMemo や useCallback とセットで使います。React.memoのページで、動く例とともに紹介しています。

import { memo } from 'react';

const ProductList = memo(function ProductList({ category }) {
    // skipped while category stays the same
});

React Compilerはこの種のメモ化をビルド時に追加してくれますが、それでもまず試すべきなのはstateの構造の変更です。作業をキャッシュするのではなく、作業そのものをなくせるからです。

レンダリングはたいてい問題ない

レンダリングはオブジェクトを返す関数呼び出しです。Reactはそれを何千回もすばやく実行し、同じ出力を生むレンダリングはDOMを変えません。「念のため」にあらゆる場所に memo を付けないでください。比較にはそれぞれコストがあり、コードも読みにくくなります。まず測ってください。React DevToolsのProfilerを使えば、どのコンポーネントが、なぜレンダリングされ、それぞれどれだけ時間がかかったかがわかります。

よくある質問

Reactのコンポーネントが再レンダリングされるきっかけは何ですか?

3つあります。自分のstateが変わったとき、親がレンダリングされたとき、そして useContext で読んでいるコンテキストが新しい値を受け取ったときです。propsは独立したきっかけではありません。新しいpropsが届くのは、親がレンダリングされたからにすぎません。

親が再レンダリングされると子も再レンダリングされますか?

はい。デフォルトでは、レンダリングされる親の中にあるすべてのコンポーネントが、propsが変わっていなくてもレンダリングされます。子を memo で包むと、propsが前回と同じならReactはその子を飛ばせます。

Reactの仮想DOMとは何ですか?

コンポーネントが返す普通のJavaScriptオブジェクト(React要素)のツリーを、ゆるく呼んだ名前です。Reactは新しいツリーを前回のものと比べ、違う部分の実際のDOMノードだけを変更します。

再レンダリングはパフォーマンスに悪いですか?

たいていは悪くありません。レンダリングはオブジェクトを生成する関数呼び出しで、Reactは出力が変わった場所でしかDOMに触れません。React DevToolsのProfilerなどで、レンダリングが目に見えて遅いと測れたときに最適化してください。

レンダリングとコミットの違いは何ですか?

レンダリングは、画面がどう見えるべきかを知るためにReactがコンポーネントを呼ぶことです。コミットは、Reactがその差分をDOMに適用することです。同じ出力を生むレンダリングは、DOMに何の変更もコミットしません。

Coddyのプログラミング言語のイラスト

Coddyでコードを学ぼう

始める