Reactで再レンダリングを抑えるシンプルな方法
はじめに
「React で再レンダリングを抑えたい...」となった場合、多くの人が React.memo
や useMemo
、useCallback
などのいわゆる 「メモ化」 を思い浮かべることでしょう。
しかし、そういった「メモ化」を用いなくても再レンダリングを抑える方法が実は存在しています。
今回はその代表的な例を2つ紹介していきたいと思います。
よくある例
まず例として、以下のような 「パフォーマンスに問題を抱えたコンポーネント」 を考えてみましょう。
import { useState } from "react";
export default function App() {
let [color, setColor] = useState("red");
return (
<div>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p style={{ color }}>Hello, world!</p>
<ExpensiveTree />
</div>
);
}
function ExpensiveTree() {
let now = performance.now();
while (performance.now() - now < 100) {
// Artificial delay -- do nothing for 100ms
}
return <p>I am a very slow component tree.</p>;
}
この実装の致命的な欠陥として、App コンポーネントの内部でカラーが変化するたびに<ExpensiveTree />
(疑似的にレンダリングコストを大きくしているコンポーネント)が再レンダリングされてしまうことが挙げられます。
実際にカラーを入力すると動作が重いことを体感できるかと思います。
こういった問題は<ExpensiveTree />
にmemo()
を使用すれば解決できる問題ではありますが、今回はあえて使用せずに考えてみます。
解決策
① コロケーション
まず 1 つ目が「コロケーション」です。
もう少しわかりやすく言うと 「より関連性のある場所に state を配置する」 ということです。
コロケーションとは?
「コロケーション(co-location)」 とは関連するリソース同士を近くに置いておくという考え方のことです。詳細は以下の Kent C. Dodds[1] 氏が執筆されたブログ記事がわかりやすいです。
今回の例だと、現在のカラーを必要としているのは App コンポーネントではないことがわかります。
(特に<ExpensiveTree />
はこのカラー情報を一切参照していません。)
以下のように返却されるツリーの一部しかこの情報は参照していません。
export default function App() {
+ let [color, setColor] = useState("red");
return (
<div>
+ <input value={color} onChange={(e) => setColor(e.target.value)} />
+ <p style={{ color }}>Hello, world!</p>
<ExpensiveTree />
</div>
);
}
ということで、これを別コンポーネントに切り出してみましょう。
export default function App() {
return (
<>
+ <Form />
<ExpensiveTree />
</>
);
}
function Form() {
+ let [color, setColor] = useState('red');
return (
<>
+ <input value={color} onChange={(e) => setColor(e.target.value)} />
+ <p style={{ color }}>Hello, world!</p>
</>
);
}
先程よりも動作が軽くなったことがわかるでしょう。
これは、以前はカラーを変更するとすべてのコンポーネントを再レンダリングしていたところが<Form />
だけを再レンダリングすればよくなったためです。
② コンポジション(children props)
① の方法では、例えば state が親の<div>
に配置されてしまった場合に対応できません。
export default function App() {
+ let [color, setColor] = useState('red');
return (
+ <div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p>Hello, world!</p>
<ExpensiveTree />
</div>
);
}
こうすると先程のように<Form />
に分割したとて、親の<div>
を再レンダリングする必要が出てきてしまい、必然的に高価な<ExpensiveTree>
を再計算せざるを得ません。
このような場合にどう対処すれば良いのでしょうか?
ここで登場するのが 「コンポジション(children)」 です。
コンポジションとは?
「コンポジション」とはいわゆる 「ReactNode 型」の Props を扱うことを指します。
詳細は以下のドキュメントを参照してください。
結論から言うと以下のようにすれば無駄な再レンダリングを防げます。
export default function App() {
return (
+ <ColorPicker>
<p>Hello, world!</p>
<ExpensiveTree />
+ </ColorPicker>
);
}
function ColorPicker({ children }) {
+ let [color, setColor] = useState("red");
return (
+ <div style={{ color }}>
+ <input value={color} onChange={(e) => setColor(e.target.value)} />
+ {children}
+ </div>
);
}
App コンポーネントを 「(カラー情報に依存して)変更が必要な部分」 と 「そうでない部分」 の 2 つに分割しています。
カラー依存部とカラーの状態変数そのものは<ColorPicker>
に移動させ、カラー情報を必要としない部分は<App>
内に残し、ReactNode
型の Props である Children として<ColorPicker>
に渡します。
こうすることで、カラーが変化するたびに<ColorPicker>
は再レンダリングされますが、前回レンダリング時に<App>
から取得したものと同じ Children を保持しているため<ExpensiveTree>
の再レンダリングは起こらなくなります。
さらに最終的なコードを見れば、パフォーマンス面だけでなく
-
コンポーネントの責務をはっきりと分離させることができる
- 今回だとカラー情報が必要でない部分とそうでない部分
-
データフローを見やすくし、無駄な props のバケツリレー(Props drilling) を防ぐ
- 深い依存関係のツリーを浅くする
といった利点も得られることがわかります。
このあたりについて詳細に知りたい人は以下の資料が非常に参考になります。
【補足】なぜ React は Children を再レンダリングしないのか?
さてここで次のような疑問を思った方も多いのではないでしょうか?
「(state が更新されても) React はなぜ Children を再レンダリングしないのか?」
まず前提として、React は state を更新すると子コンポーネントを再帰的にレンダリングします。
しかし、React は差分検出処理(reconciliation)を利用して DOM の必要最小限の部分のみしか更新を行いません。
例えば、前回のレンダリング時から 「参照同一性」 を保持するような要素を見つけた場合、React はコミット[2]を停止します。
特にコンポーネントに渡された props は、state が更新され再レンダリングされたとしてもその「参照同一性」を保持します。
このような背景があるため、コンポジションを用いるとメモ化を用いなくてもパフォーマンス改善が可能になります。
応用例
最後に応用例として上記 2 つの方法を用いながら、実際にアプリケーションのパフォーマンス改善に取り組んでみましょう。
以下のアプリケーションではマウスカーソルを動かすたびに、<LongList />
が再レンダリングされてしまい、パフォーマンスに問題が生じています。
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
function onMouseMove(e) {
setPosition({ x: e.clientX, y: e.clientY });
}
return (
<div onMouseMove={onMouseMove}>
<header className="p-4 bg-slate-200 sticky top-0">
<p>Mouse position is:</p>
<p>X: {position.x}</p>
<p>Y: {position.y}</p>
</header>
{/** マウスが動く度にここが再レンダリングされてしまう */}
<LongList />
</div>
);
}
// 10000個のItemをレンダリングするため非常に計算コストが高い
function LongList() {
return (
<div className="p-4">
{[...Array(10000).keys()].map((i) => {
return <p key={i}>Item {i + 1}</p>;
})}
</div>
);
}
export default function App() {
return <MouseTracker />;
}
まずは「コロケーション」を用いましょう。
これにより state を適切な場所へ再配置しましょう。今回だと、マウスカーソルの座標更新に必要な state を移動させてみましょう。
以下のマウスカーソルの更新に関連するコンポーネントを別部分に切り出すことにします。
function MouseTracker() {
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ function onMouseMove(e) {
+ setPosition({ x: e.clientX, y: e.clientY });
+ }
return (
+ <div onMouseMove={onMouseMove}>
+ <header className="p-4 bg-slate-200 sticky top-0">
+ <p>Mouse position is:</p>
+ <p>X: {position.x}</p>
+ <p>Y: {position.y}</p>
+ </header>
<LongList />
+ </div>
);
}
// マウスカーソルの更新に関連するコンポーネントを切り出す
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
function onMouseMove(e) {
setPosition({ x: e.clientX, y: e.clientY });
}
return (
<div onMouseMove={onMouseMove}>
<header className="p-4 bg-slate-200 sticky top-0">
<p>Mouse position is:</p>
<p>X: {position.x}</p>
<p>Y: {position.y}</p>
</header>
</div>
);
}
export default function App() {
return <MouseTracker />;
}
次に、コンポジションを用います。
つまり、先ほど作成したコンポーネントに Children を渡し、見通しよくしてみましょう。
// Childrenを受け取れるようにする
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
function onMouseMove(e) {
setPosition({ x: e.clientX, y: e.clientY });
}
return (
<div onMouseMove={onMouseMove}>
<header className="p-4 bg-slate-200 sticky top-0">
<p>Mouse position is:</p>
<p>X: {position.x}</p>
<p>Y: {position.y}</p>
</header>
{children}
</div>
);
}
function LongList() {
return (
<div className="p-4">
{[...Array(10000).keys()].map((i) => {
return <p key={i}>Item {i + 1}</p>;
})}
</div>
);
}
// <LongList />をChildrenとして渡す
export default function App() {
return (
<MouseTracker>
<LongList />
</MouseTracker>
);
}
こうすることでマウスカーソルの状態更新を、その更新だけを担当するコンポーネント内に隔離することができます。
そして、マウスを動かすたびに JSX の高価な部分を再レンダリングする必要がなくなり、パフォーマンスも改善されました。
まとめ
このようにパフォーマンスに問題がある場合は「メモ化」に頼る前にまず
- コロケーション(state を適切な場所に配置する)
- コンポジション(Children を用いてコンポーネントの責務を分割する)
以上 2 つを検討できないか試してみるとよいかもしれません。
参考文献
-
React Testing Library 開発者、Testing Trophy 提唱者 ↩︎
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion