この記事はComplete Guide to React Rendering Behaviorの翻訳記事になっています。
ご本人(Markさん)にも許可を頂いて翻訳しております。
こちらの記事がReactのレンダリングを理解する上で今までで一番体系的で一番分かりやすかったので、ぜひ紹介したく翻訳させて頂きました。
翻訳ツールにもたくさん助けてもらいながら行い、意訳が出来ていない部分が多々あるかと思いますので修正依頼を出して頂けると幸いです!
Twitterでも、フロントエンドに関する事や、アメリカでのエンジニア経験に関してツイートしているので、よかったらフォローお願いします。
Twitter: @hellokenta_ja
下記から本文です。
Complete Guide to React Rendering Behavior
この記事は、Reactレンダリングがどのように振る舞うか、ContextとReact-Reduxがレンダリングにどのように影響するのかを記載したものです。
React がいつ、なぜ、どのようにコンポーネントを再レンダリングするのか、Context と React-Redux の使用がそれらの再レンダリングのタイミングと範囲にどのように影響するのかについて、たくさんの方が混乱しているのを目の当たりにしてきました。
これについて何回も説明してきたので、私がまとまった説明を書く価値があるのではと感じました。これらの情報はすべてオンラインで入手可能であり、他の多くの優れたブログ記事や記事で説明されています。
しかしそれらは乱立していて、体系的に理解するのに苦労しているようでした。この記事が、誰かの役に立つことを願っています。
レンダリングとは何か?
レンダリングとは、現在のPropsとStateを元に、Reactがコンポーネントに対して、それらがどのように見えるべきなのかを尋ねるプロセスです。
レンダリングプロセスの概要
レンダリングプロセスの間、React はコンポーネントツリーのルートから開始し、更新が必要であるとフラグが立てられたすべてのコンポーネントを見つけるために下方にループします。フラグが設定された各コンポーネントについて、React はclassComponentInstance.render()
(クラスコンポーネントの場合) または FunctionComponent()
(関数コンポーネントの場合) のいずれかを呼び出し、レンダリング出力を保存します。
コンポーネントのレンダリング出力は通常 JSX 構文で書かれ、JS がコンパイルされてデプロイの準備が整うと、React.createElement()
コールに変換されます。例を示します。
// This JSX syntax:
return <SomeComponent a={42} b="testing">Text here</SomeComponent>
// is converted to this call:
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")
// and that becomes this element object:
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
コンポーネントツリー全体からレンダリング出力を収集した後、React はオブジェクトの新しいツリー(「仮想 DOM」と呼ばれることが多い)の差分計算をし、実際の変更すべきDOMのリストを収集します。この差分と計算のプロセスは、"reconcilation"として知られています。
そして、React は、計算されたすべての変更を1つの同期シーケンスでDOMに適用します。
レンダリングとコミットのフェーズ
Reactチームでは、この作業を概念的に2つのフェーズに分けています。
- レンダーフェーズには、コンポーネントのレンダリングと変更点の計算のすべての作業が含まれています。
- コミットフェーズは、それらの変更を DOM に適用するプロセスです。
React はコミットフェーズで DOM を更新した後、componentDidMount
とcomponentDidUpdate
クラスのライフサイクルメソッドとuseLayoutEffect
フックを同期的に実行します。
次に React は短いタイムアウトを設定し、それが終わるとすべてのuseEffect
フックを実行します。
この優れた React のライフサイクルメソッド図で、クラスのライフサイクルメソッドの可視化を見ることができます。(現在はエフェクトフックのタイミングは表示されていませんが、これは追加してほしいところです)。
Reactの"Concurrent Mode"では、レンダリングフェーズでの作業を一時停止して、ブラウザがイベントを処理できるようにすることができます。React は、その作業を再開するか、捨てるか、または後で適切な方法で再計算します。レンダリングパスが完了した後も、React はコミットフェーズを 1 ステップで同期して実行します。
このことを理解する上で重要なことは、 「レンダリング」 は「DOM の更新」と同じものではなく、結果として目に見える変化が起こらずにコンポーネントがレンダリングされることがあるということです。
React がコンポーネントをレンダリングするときは
- コンポーネントは前回と同じレンダリング出力を返すかもしれないので、変更の必要が無い場合があります。
- Concurrent Mode では、React はコンポーネントを複数回レンダリングする可能性がありますが、他の更新が現在の作業を無効にしてしまうと、毎回レンダリングの出力を捨ててしまうこともあります。
React はどのようにレンダリングを処理するのか?
レンダリングのキュー
最初のレンダリングが完了した後、再レンダリングをキューに入れるように React に指示する方法はいくつかあります。
- クラスコンポーネント
this.setState()
this.forceUpdate()
- 関数コンポーネント
-
useState
のセッター -
useReducer
dispatches
-
- その他
-
ReactDOM.render(<App>)
を再度呼び出す(これはルートコンポーネント上でforceUpdate()
を呼び出すのと同じです)
-
レンダリングの動作
React のデフォルトの動作は、親コンポーネントがレンダリングされると、React はその中のすべての子コンポーネントを再帰的にレンダリングするということ を覚えておくことは非常に重要です。
例として、A > B > C > D
のコンポーネントツリーがあり、それらをすでにページに表示しているとします。ユーザーがB
のボタンをクリックすると、カウンタがインクリメントされます。
-
B
でsetState()
を呼び出し、B
の再レンダリングをキューに入れます。 - React は、ツリーの一番上からレンダーパスを開始します。
- React は、
A
が更新の必要性があるとマークされていないことを見て、それを通過します。 - React は
B
が更新が必要とマークされていることを見て、それをレンダリングします。B
は前回と同じように<C />
を返します。 -
C
はもともと更新が必要とマークされていませんでした。しかし、親のB
がレンダリングしたので、React は下に移動してC
もレンダリングします。C
は再び<D />
を返します。 -
D
もまた、レンダリングのためにマークされていませんでしたが、親のC
がレンダリングしたので、React は下方向に移動し、D もレンダリングします。
要するに、
コンポーネントをレンダリングすると、デフォルトでは、その中にあるすべての子コンポーネントもレンダリングされます!
また、もう一つの重要なポイントがあります。
通常のレンダリングでは、React は "props が変更された "かどうかを気にしません! すなわち、親がレンダリングされると、子コンポーネントを無条件にレンダリングします!これは、root の<App />
の中でsetState()
を呼び出すことは、ツリーの中の全ての子コンポーネントをレンダリングすることを意味します。
ツリー内のほとんどのコンポーネントが前回と全く同じレンダリング出力を返す可能性が高いので、ReactはDOMに変更を加える必要はありません。しかし、Reactはコンポーネントにレンダリングを依頼し、レンダリング出力を差分する作業をしなければなりません。どちらも時間と労力がかかります。
レンダリングは悪いことではないことを覚えておいてください。それは、Reactが実際にDOMに変更を加える必要があるかどうかを知る方法だからです。
レンダリングのパフォーマンスを改善する
そうは言っても、レンダリング作業が「無駄な」努力になることがあるのも事実です。コンポーネントのレンダリング出力が変化せず、DOM のその部分を更新する必要がない場合、そのコンポーネントをレンダリングする作業は時間の無駄になります。
React コンポーネントのレンダリング出力は、常に現在のPropsとStateに基づいていなければなりません。したがって、コンポーネントのPropsとStateが変更されていないことが事前にわかっていれば、レンダリング出力は同じであり、このコンポーネントに変更は必要なく、安全にレンダリング作業をスキップできるはずです。
一般的にソフトウェアのパフォーマンスを向上させようとする場合、2 つの基本的なアプローチがあります。1) 同じ作業をより速く行うこと、2) 作業を減らすことです。React のレンダリングを最適化することは、主に、適切な場合にレンダリングコンポーネントをスキップすることで、より少ない作業を行うことです。
コンポーネントレンダリングの最適化テクニック
React には、潜在的にコンポーネントのレンダリングをスキップできる 3 つの主要な API があります。
-
React.Component.shouldComponentUpdate
: オプショナルなクラスコンポーネントのライフサイクルメソッドで、レンダリングプロセスの初期に呼び出されます。これがfalse
を返すと、React はコンポーネントのレンダリングをスキップします。このブール値の結果を計算するために使用したい任意のロジックを含むことができますが、最も一般的なアプローチは、コンポーネントのPropsと状態が前回から変更されているかどうかをチェックし、変更されていない場合はfalse
を返すことです。 -
React.PureComponent
:props
とstate
の比較は、shouldComponentUpdate
を実装する上で最も一般的なので、PureComponent
では、デフォルトでその動作を実装しており、Component
+shouldComponentUpdate
の代わりに使用することができます。 -
React.memo()
: 組み込みの「Higher Order Component」型。コンポーネント型を引数として受け取り、新しいラッパーコンポーネントを返します。ラッパーコンポーネントの動作は、Propsが変更されたかどうかをチェックし、変更されていない場合は再レンダリングを防ぎます。関数コンポーネントとクラスコンポーネントの両方とも、React.memo()
を使ってラップすることができます。(カスタム比較コールバックを渡すこともできますが、実際には新旧のPropsを比較することしかできないので、カスタム比較コールバックの主な用途は、すべてのPropsフィールドを比較するのではなく、特定のPropsフィールドだけを比較することになります)。
これらのアプローチはすべて、「Shallow Equality」 と呼ばれる比較テクニックを使用しています。これは、2 つの異なるオブジェクトの個々のフィールドをチェックして、オブジェクトの内容が異なる値であるかどうかを確認することを意味します。言い換えれば、obj1.a === obj2.a && obj1.b === obj2.b && ........
ということです。これは、JS エンジンが行う===
比較が非常に簡単なので、通常は高速に処理されます。つまり、これら 3 つのアプローチは、const shouldRender = !shallowEqual(newProps, prevProps)
と同等の処理を行います。
また、あまり知られていないテクニックもあります。React コンポーネントがそのレンダリング出力で前回と全く同じ要素参照を返す場合、React はその特定の子要素の再レンダリングをスキップします。
これらのテクニックのすべてにおいて、コンポーネントのレンダリングをスキップするということは、React がそのサブツリー全体のレンダリングをスキップすることを意味します。なぜなら、これはデフォルトの「子を再帰的にレンダリングする」動作を停止させるためのストップサインを出しているからです。
新しい Props の参照がレンダリングの最適化に与える影響
デフォルトでは、React は入れ子になったコンポーネントのPropsが変更されていなくても、すべてのコンポーネントを再レンダリングすることをすでに見てきました。これは、新しい参照を子コンポーネントにPropsとして渡しても、同じPropsを渡しても渡さなくてもレンダリングされるので、問題にならないことも意味します。ですから、このようなものは全く問題ありません。
function ParentComponent() {
const onClick = () => {
console.log("Button clicked");
};
const data = { a: 1, b: 2 };
return <NormalChildComponent onClick={onClick} data={data} />;
}
ParentComponent
がレンダリングするたびに、新しいonClick
関数参照と新しいデータオブジェクト参照を作成し、それらを Props として NormalChildComponent
に渡します。(onClick
を function
キーワードで定義しているか Arrow 関数として定義しているかは関係ありません。)
これは、<div>
や<button>
のような「ホストコンポーネント」をReact.memo()
で包んでレンダリングを最適化しようとしても意味がないことを意味します。これらの基本的なコンポーネントの下には子コンポーネントがないので、レンダリング処理はいずれにせよそこで止まってしまいます。
しかし、子コンポーネントが Props が変更されたかどうかをチェックすることでレンダリングを最適化しようとしている場合、新しい参照を Props として渡すと、子コンポーネントはレンダリングを行います。新しい Props 参照が実際に新しいデータであれば、これは良いことです。しかし、親コンポーネントがコールバック関数を渡しているだけの場合はどうでしょうか?
const MemoizedChildComponent = React.memo(ChildComponent);
function ParentComponent() {
const onClick = () => {
console.log("Button clicked");
};
const data = { a: 1, b: 2 };
return <MemoizedChildComponent onClick={onClick} data={data} />;
}
ParentComponent
がレンダリングするたびに、これらの新しい参照によって、MemoizedChildComponent
は Props の値が新しい参照に変更されたことを確認し、再レンダリングを行うようになります、、、
onClick
関数やdata
オブジェクトは基本的に同じであるにも関わらずです!
つまり、MemoizedChildComponent
は、レンダリングをスキップしたかったにもかかわらず、常に再レンダリングを行います。
Props の比較をすることは、ただの無駄な労力になっているわけです。
同様に、<MemoizedChild><OtherComponent /></MemoizedChild>
において、props.children
は常に新しい参照であるため、常に子コンポーネントがレンダリングされることに注意してください。
Props 参照の最適化
クラスコンポーネントは、常に同じ参照であるインスタンスメソッドを持つことができるので、新しいコールバック関数の参照を誤って作成してしまうことをそれほど心配する必要はありません。しかし、別々の子リスト項目のためにユニークなコールバックを生成したり、匿名関数の中で値をキャプチャして、それを子に渡したりする必要があるかもしれません。これらは新しい参照になり、レンダリング中に子 Props として新しいオブジェクトを作成することになります。React には、これらのケースを最適化するためのビルトインはありません。
関数コンポーネントについては、React には同じ参照を再利用するのに役立つ 2 つのフックがあります:オブジェクトを作成したり複雑な計算をしたりするような一般的なデータのためのuseMemo
と、コールバック関数を作成するためのuseCallback
です。
全てのコンポーネントを memo するべきですか?
上述したように、Props として渡す関数やオブジェクトの全てにuseMemo
とuseCallback
を使う必要はありません。子コンポーネントに対する影響がある時だけ利用すれば良いのです。(とはいえuseEffect
によって、子コンポーネントが一貫した Props 参照を受け取りたいケースがあるので、複雑ではありますが、、、)
もう 1 つの疑問は、「なぜ React はデフォルトですべてをReact.memo()
でラップしないのか」ということです。
Dan Abramov 氏は、メモ化にはまだ Props の比較のためのコストがかかることや、コンポーネントが常に新しいPropsを受け取るため、メモ化のチェックでは再レンダリングを防ぐことができないケースが多いことを何度も指摘してきました。。一例として、Dan 氏のこのTwitter スレッドをご覧ください。
React コンポーネントのレンダリングパフォーマンスを測定する
React DevTools Profilerを使って、各コミットでどのコンポーネントがレンダリングされているかを確認します。予期せぬレンダリングをしているコンポーネントを見つけ、DevTools を使ってレンダリングの原因を突き止め、修正しましょう (React.memo()
でラップするか、親コンポーネントが子コンポーネントに渡す Props をメモ化しましょう)。
また、React は開発ビルドではかなり遅く動作することを覚えておいてください。開発モードでアプリをプロファイルして、どのコンポーネントがレンダリングされているか、その理由を確認したり、コンポーネントのレンダリングに必要な相対的な時間をお互いに比較したりすることができます(「コンポーネント B はコンポーネント A よりもこのコミットでレンダリングに 3 倍の時間がかかった」というように)。しかし、React の開発ビルドを使って絶対的なレンダリング時間を測定してはいけません。絶対的な時間は本番用ビルドでのみ計測してください!(そうしないと、正確ではない数値を使っていると Dan Abramov が怒鳴りつけてきます)。プロファイラを使って prod のようなビルドからタイミングデータを取得したい場合は、React の特殊な「プロファイリング」ビルドを使う必要があることに注意してください。
Context とレンダリング
React の Context API
は、ユーザーが提供する単一の値をコンポーネントのサブツリーで利用できるようにするための仕組みです。与えられた <MyContext.Provider>
の中のどのコンポーネントも、そのコンテキストインスタンスから値を読み取ることができます。
コンテキストは「状態管理」ツールではありません。コンテキストに渡される値は自分で管理しなければなりません。これは通常、React コンポーネントの State にデータを保持し、そのデータに基づいて context の値を構築することで行われます。
Context の基本
コンテキストプロバイダは<MyContext.Provider value={42}>
のような単一のvalue props
を受け取ります。子コンポーネントは、コンテキストのコンシューマコンポーネントをレンダリングして、次のようにレンダリングPropsを提供することでコンテキストを消費することができます。
<MyContext.Consumer>{ (value) => <div>{value}</div>}</MyContext.Consumer>
のようにレンダリングPropsを提供することでコンテキストを消費することができます。
または、関数コンポーネントのuseContext
フックを呼び出すことで、以下のようになります。
const value = useContext(MyContext)
Context の値のアップデート
React は、コンテキストのプロバイダに新しい値が与えられているかどうかをチェックします。Provider の値が新しい参照になっている場合、React は値が変更され、そのコンテキストを消費するコンポーネントを更新する必要があることを知っています。
コンテキストプロバイダに新しいオブジェクトを渡すと、それが更新されることに注意してください。
function GrandchildComponent() {
const value = useContext(MyContext);
return <div>{value.a}</div>;
}
function ChildComponent() {
return <GrandchildComponent />;
}
function ParentComponent() {
const [a, setA] = useState(0);
const [b, setB] = useState("text");
const contextValue = { a, b };
return (
<MyContext.Provider value={contextValue}>
<ChildComponent />
</MyContext.Provider>
);
}
この例では、ParentComponent
がレンダリングするたびに、React はMyContext.Provider
に新しい値が与えられたことを感知して、MyContext
を消費するコンポーネントを探します。コンテキストプロバイダに新しい値が与えられると、そのコンテキストを消費するネストされたコンポーネントはすべて再レンダリングされます。
React の観点からすると、各コンテキストプロバイダは 1 つの"value"しか持たないということに注意してください。それがオブジェクトであれ、配列であれ、プリミティブであれ、1 つのコンテキスト値を持ちます。現在のところ、コンテキストを消費するコンポーネントが、その値の中の一部の値だけを使用していたとしても、新しいコンテキスト値による更新をスキップする方法はありません。
State の更新、コンテキスト、再レンダリング
これまでのことをまとめてみましょう。
-
setState()
を呼び出すと、そのコンポーネントのレンダリングをキューに入れる。 - React はデフォルトでネストしたコンポーネントを再帰的にレンダリングする。
- コンテキストプロバイダには、それらをレンダリングするコンポーネントから値が与えられます。
- この値は通常、その親コンポーネントから来ます。
つまり、デフォルトでは、親コンポーネントの State が更新されると、コンテキスト値を読み込んでいるかどうかに関わらず、その子孫のすべてが再レンダリングされるのです!
先ほどのParent/Child/Grandchild
の例を見てみると、GrandchildComponent
は再レンダリングしますが、コンテキストの更新が原因ではなく、ChildComponent
がレンダリングしたために再レンダリングしてしまいます。この例では、「不要な」レンダリングを最適化していないので、React はParentComponent
がレンダリングするたびに、ChildComponent
とGrandchildComponent
をデフォルトでレンダリングします。親がMyContext.Provider
に新しいコンテキスト値を入れた場合、GrandchildComponent
はレンダリングするときに新しい値を使用しますが、それはコンテキストの更新によってGrandchildComponent
がレンダリングされるのではなく、親コンポーネントのレンダリングによって起こるのです。
コンテキストのアップデートとレンダリングの最適化
上記の例をレンダリングを最適化するように修正してみましょう。
function GreatGrandchildComponent() {
return <div>Hi</div>
}
function GrandchildComponent() {
const value = useContext(MyContext);
return (
<div>
{value.a}
<GreatGrandchildComponent />
</div>
)
}
function ChildComponent() {
return <GrandchildComponent />
}
const MemoizedChildComponent = React.memo(ChildComponent);
function ParentComponent() {
const [a, setA] = useState(0);
const [b, setB] = useState("text");
const contextValue = {a, b};
return (
<MyContext.Provider value={contextValue}>
<MemoizedChildComponent />
</MyContext.Provider>
)
}
例えば、setA(42)
を呼ぶとしましょう。
-
ParentComponent
がレンダーされます -
contextValue
の新しい参照が作られます - React は
MyContext.Provider
が新しいコンテキストの参照を渡されていて、それをコンシュームしているコンポーネントが更新されなければいけないことを確認します。 - React は
MemoizedChildComponent
のレンダリングを試みますが、それはReact.memo()
でラップされています。このコンポーネントには一切 props を渡していないので props が変更されることももちろんありません。よって、React はChildComponent
のレンダリングをスキップします。 - ただし、
MyContext.Provider
のアップデートがあったので、そのことを知っておく必要があるコンポーネントがさらに下の階層にあるかもしれません。 - React はさらに下に進み、
GrandchildComponent
に到達します。そして、MyContext
がGrandchildComponent
によって読み込まれていて、再レンダリングする必要があることを確認します。React はコンテキストの値が変更されたことによりGrandchildComponent
を再レンダリングします。 -
GrandchildComponent
はレンダリングしたので、React はその後も続行し、その中にあるコンポーネントは全てレンダリングします。つまり、React はGreatGrandchildComponent
も再レンダリングします。
コンテキストプロバイダー直下のコンポーネントには
React.memo
を使うべき
この方法では、親コンポーネントの状態が更新されても、すべてのコンポーネントが再レンダリングされることはなく、コンテキストが読み込まれた部分だけがレンダリングされます。(ParentComponent
内では<MyContext.Provider>{props.children}</MyContext.Provider>
をレンダリングし、1 つ上の階層から<ParentComponent><ChildComponent /></ParentComponent>
をレンダリングさせることで、"同じ要素の参照"をさせることができ、同じ結果を得ることもできます)。
しかし、GrandchildComponent
が新しいコンテキスト値に基づいてレンダリングすると、React はすべてを再帰的に再レンダリングするというデフォルトの動作を行います。つまり、GreatGrandchildComponent
がレンダリングされ、その下にある他のものもレンダリングされるということです。
React-Redux とレンダリングの動作
「CONTEXT VS REDUX?!!!!!」は、私が今 React コミュニティで最もよく目にする質問の一つであるように思えます。(この質問はそもそも間違った二分法です。Redux と Context は異なることをする別のツールなので)。
とはいえ、この質問が出てきたときに繰り返し指摘されることの一つに、「React-Redux は実際にレンダリングする必要のあるコンポーネントのみを再レンダリングするので、コンテキストよりも優れている」というものです。
それはある意味その通りですが、答えはそれほどシンプルではありません。
React-Reduxのサブスクリプション
"React-Redux は内部でコンテキストを使用します" というフレーズを繰り返す人をよく見かけます。技術的には正しいのですが、React-Reduxは現在の状態値ではなく、Reduxストアのインスタンスを渡すためにコンテキストを使用します。つまり、常に同じコンテキスト値を<ReactReduxContext.Provider>
に渡しています。
アクションがディスパッチされるたびに、Reduxストアはすべてのサブスクライバーに通知コールバックを実行することを覚えておいてください。Reduxを使用する必要があるUIレイヤーは、常にReduxストアにサブスクライブし、最新のStateを読み取り、値を差分し、関連するデータが変更された場合には再レンダリングします。サブスクリプションコールバックプロセスは完全にReactの外で行われ、Reactが関与するのは、特定のReactコンポーネントが必要とするデータが変更されたことをReact-Reduxが知っている場合のみです(mapState
またはuseSelector
の戻り値に基づく)。
この結果、Context APIとは異なるパフォーマンスの特性をもつことになります。たしかに、レンダリングするコンポーネントの数は少なくなりそうですが、React-Redux
はストアの状態が更新されるたびに、コンポーネントツリー全体のmapState/useSelector
関数を常に実行しなければなりません。ほとんどの場合、これらのセレクタを実行するコストは、Reactが別のレンダリングパスを実行するコストよりも少なくて済みます。 しかし、これらのセレクタが計算量の高い変換を行っていたり、誤って新しい値を返してしまったりすると、アプリケーションの速度を遅くしてしまいます。
connect
とuseSelector
の違い
connect
はHigher Order Component
です。コンポーネントを渡すと、connect
はstoreへのサブスクライブ、mapState
とmapDispatch
の実行、そして結合されたprops
をそのコンポーネントに渡すというすべての作業を行うラッパーコンポーネントを返します。
connect
のラッパーコンポーネントは、常にPureComponent/React.memo()
と同じように動作しますが、connect
は、コンポーネントに渡す ”最終的に結合されたprops
” が変更された場合にのみ、自分のコンポーネントをレンダリングします。通常、最終的に結合されるpropsは{...ownProps, ...stateProps, ...dispatchProps}
の組み合わせになりますので、親からの新しいProps参照は、PureComponent
やReact.memo()
と同じように、実際にコンポーネントをレンダリングします。親Props以外にも、mapState
から返された新しい参照もコンポーネントをレンダリングします。(ownProps/stateProps/dispatchProps
がどのようにマージされるかをカスタマイズすることができるので、その動作を変更することも可能です)。
一方、useSelector
は、あなたの関数コンポーネントの内部で呼び出されるフックです。そのため、親コンポーネントがレンダリングされる時に、 useSelector
はコンポーネントのレンダリングを止めることができないのです!
これがconnect
とuseSelector
のパフォーマンスの違いです。connect
の場合、接続されたすべてのコンポーネントは PureComponent
のように動作し、React のデフォルトのレンダリング動作がコンポーネントツリー全体に再帰的にレンダリングするのを防ぐためのファイアウォールとして機能します。典型的なReact-Reduxアプリは多くのconnect
されたコンポーネントを持っているので、ほとんどの再レンダリングの振る舞いはコンポーネントツリーのかなり小さな部分に限定されます。React-Reduxはconnectされているコンポーネントをデータの変更に基づいてレンダリングし、その下にある次の2~3個のコンポーネントも同様にレンダリングしますが、次のconnectされていてかつレンダリングの必要がないコンポーネントに当たった時はレンダリングをストップします。
さらに、connectされているコンポーネントが多いということは、各コンポーネントがStoreからより小さなデータを読み込んでいる可能性が高いことを意味します。
関数コンポーネントとuseSelector
のみを使用している場合、コンポーネントツリーのより大きな部分が、Redux ストアの更新に基づいて再レンダリングされる可能性があります。
これがパフォーマンスの問題になっている場合は、必要に応じてコンポーネントをReact.memo()
でラップして、親コンポーネントによる不必要な再レンダリングを防ぐ必要があります。
Summary
- Reactは常にデフォルトでコンポーネントを再帰的にレンダリングするので、親がレンダリングするとその子もレンダリングします。
- レンダリングはそれ自体は問題ありません。それはReactがどのようなDOMの変更が必要かを知る手法です。
- しかし、レンダリングには時間がかかり、UI出力が変化しなかった「無駄なレンダリング」は増える可能性があります。
- ほとんどの場合、コールバック関数やオブジェクトのような新しい参照を渡しても構いません。
-
React.memo()
のようなAPIは、propsが変更されていない場合、不要なレンダリングをスキップすることができます。 - しかし、常に新しい参照を
props
として渡してしまうと、React.memo()
はレンダリングをスキップすることができないので、それらの値自体をメモしておく必要があります。 - Context APIは、ネストされたコンポーネントから値にアクセスできるようにします。
- コンテキストプロバイダは、値が変更されたかどうかを知るために、その値を参照して比較します。
- 新しいコンテキスト値は、すべてのネストされたコンシューマに再レンダリングを強制します。
- しかし、多くの場合、通常の親->子の再帰的なレンダリング処理により、子はいずれにせよ再レンダリングされています。
- そのため、コンテキスト値を更新したときにツリー全体が常にレンダリングされないように、コンテキストプロバイダの子を
React.memo()
でラップするか、{props.children}
を使用することをお勧めします。 - 子コンポーネントが新しいコンテキスト値に基づいてレンダリングされると、Reactはそこからも再帰的にレンダリングを続けます。
- React-Reduxは、コンテキストによってストアの状態値を渡すのではなく、更新のチェックをするためにReduxストアへのサブスクリプションを使用しています。
- これらのサブスクリプションはReduxストアが更新されるたびに実行されるので、できるだけ高速である必要があります。
- React-Reduxは、データが更新されているコンポーネントが再レンダリングされるようにするためにかなり多くの作業をしています。
-
connect
はReact.memo()
のように動作するので、connectされたコンポーネントがたくさんあるということは、一度にレンダリングするコンポーネントの総数を最小限に抑えることができるという事です。 -
useSelector
はフックなので、親コンポーネントによるレンダリングを止めることはできません。useSelector
だけを使用しているアプリでは、レンダリングが常に再帰的に行われるのを避けるために、いくつかのコンポーネントにReact.memo()
を追加する必要があります。
結論
明らかに、「コンテキストが全てのコンポーネントのレンダリングを行い、Reduxはそうではないので、Reduxを使用しよう!」というような単純なものではなく、はるかに複雑です。誤解しないでほしいのは、Redux を使ってほしいというわけではなく、さまざまなツールの動作やトレードオフを明確に理解して、自分のユースケースに最適なものは何なのかということを、十分な情報に基づいて判断できるようにしてほしいということです。
誰もがいつも「いつコンテキストを使うべきか、いつ(React-)Reduxを使うべきか」という質問をしているようなので、いくつかおさらいしましょう。
- コンテキストを使用する場合。
- 頻繁に変更されないシンプルな値を渡す必要がある場合
- アプリの一部のみでアクセスする必要がある状態や関数があり、それらをずっとPropsとして渡したくない場合。
- Reactに内蔵されているもののみを利用したく、追加のライブラリを追加したくない場合。
- (React-)Redux を使用する場合
- アプリ内の多くの場所で必要とされるアプリケーションステートがたくさんある場合
- Stateが頻繁に更新される場合
- 状態を更新するロジックが複雑な場合
- 中規模または大規模なコードベースを持ち、多くの人が作業する可能性があるアプリ
これらは厳粛なルールではなく、ただのガイドラインに過ぎないことに注意して下さい。
あなた自身でどのツールがベストなのかを考える時間を取って下さい。
この記事が皆様のReactのレンダリングの全体像の理解に役立つことを願っています。