[React] 再レンダリングするかどうかの計算コストもタダではない
はじめに
こんにちは。株式会社TERASSでエンジニアをしている myrear です。
今回は React のパフォーマンスチューニングを行っている中で得られた知見を共有します。
概要
React には useSyncExternalStore という、 React 外部の値を購読するためのフックがあります。
これ自体の詳細はドキュメントの方を読んでいただくとして、ポイントとなるのは useSyncExternalStore フックに引数として渡される getSnapshot 関数です。
getSnapshot 関数の返す値が異なればコンポーネントを再レンダリングする、逆に言えば値が同じなら再レンダリングしない、という挙動になっています。
コンポーネントが必要とするストアにあるデータのスナップショットを返す関数。ストアが変更されていない場合、getSnapshot への再呼び出しは同じ値を返す必要があります。ストアが変更されて返された値が(Object.is で比較して)異なる場合、React はコンポーネントを再レンダーします。
まずはこの挙動を確認するために useSyncExternalStore を使った簡易的なカスタムフックを実装します。
const store: Record<string, string | undefined> = {}
const listeners: Set<() => void> = new Set()
const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const getStoreValue = (key: string) => {
return store[key] || ''
}
export const useStore = (key: string) => {
const value = useSyncExternalStore(
subscribe,
useCallback(() => getStoreValue(key), [key])
)
const update = useCallback(
(newValue: string) => {
store[key] = newValue
listeners.forEach((listener) => listener())
},
[key]
)
return {
value,
update,
}
}
次に useStore を使うコンポーネントの実装です。
ここでは input 要素によってストアの値を簡単に書き換えられるようにします。
export const TextInput = ({ name, label }: { name: string; label: string }) => {
const { value, update } = useStore(name)
return (
<label style={{ padding: '16px', border: '1px solid white' }}>
<div style={{ marginBottom: '4px' }}>{label}</div>
<input value={value} onChange={(e) => update(e.target.value)} />
</label>
)
}
TextInput コンポーネントの利用側はこんな感じです。
function App() {
return (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{Array.from({ length: 50 }).map((_, i) => (
<TextInput name={`${i}`} label={`input ${i}`} key={i} />
))}
</div>
</>
)
}
react devtools で再レンダリングされたコンポーネントのハイライトを有効化してみると入力のあったコンポーネントのみが再レンダリングされていることが確認できます。

ここまででなんとなく useSyncExternalStore の挙動が理解できたかなと思います。
本題
問題です。
store の値が変更されたとき( input に文字が入力されたとき)に getStoreValue 関数は何回呼ばれるでしょうか?
...(シンキングタイム)
答えは「レンダリングされているコンポーネントの数(= App.tsx の例だと50)」です。
それもそのはずで、 useSyncExternalStore に渡された getSnapshot は getStoreValue の返す値を比較してコンポーネントを再レンダリングするかどうかを返すからです。
では、 getStoreValue の処理を重くしてみるとどうなるでしょうか?
例として無意味なループを挟みます(動作確認の場合はループの回数を調整してください)。
const getStoreValue = (key: string) => {
+ let result = 0
+ for (let i = 0; i < 10000000; i++) {
+ result += Math.sqrt(i)
+ }
return store[key] || ''
}
その後文字を入力してみるとかなりカクつくことが確認できます( gif だとわかりづらいかもですがタイプした文字列が input に現れるまでがかなり遅くなっています)。
初回レンダリングすら結構遅いです。

当然 getStoreValue の呼び出し回数を減らすことで多少緩和することができるので、レンダリングするコンポーネントの数を減らしていくとちょっとずつマシになっていきます。

2個に減らすとカクつかなくなる
この問題の厄介なところは react devtools から発見しにくいところです。
gif を見ていただくとわかりますが再レンダリング自体は変更のあったコンポーネントのみ行われており、当然プロファイラーも再レンダリングされたコンポーネントしか計測してくれません。
こうなるとプロファイラーを見ても「再レンダリングの範囲に問題はなく、そのレンダリング自体に異常な時間がかかっているわけでもない。」となり詰まってしまいます(ました)。
どういうところで問題になるのか
実際のプロダクションコードで useSyncExternalStore を使うことはあまりないと思いますが、ライブラリ内部では利用されていることがあります。
利用シーンが多そうなのは zustand のような状態管理ライブラリや tanstack-form のようなフォームライブラリあたりでしょうか。
筆者が同様の問題に遭遇したのは tanstack-form を使った500個近いコントロールを持つフォームでした。
tanstack-form もかなり大雑把に言えば先程のコード例に近い形でフォームの値を保持・参照しています。
tanstack-form での getStoreValue 相当の処理はかなり重く、フォームが大きくなるにつれ初回レンダリングや入力時のパフォーマンスが低下していきました。
react devtools でプロファイリングしても再レンダリングの範囲は期待通りで、その再レンダリングにかかっていた時間も体感とは乖離しており、ライブラリの実装を読んでいくうちに今回の結論に至った、という流れになります。
解決策
問題の根本は「一つの巨大なストアを多数のコンポーネントで同時に購読している」ことです。
そのため次のような解決策を採ることができます。
ストアを分割する
最も単純な解決策です。
巨大な一つのストアをストアA、ストアB、 ... といった形で分割していけば、ストアAの値が変わってもストアBを購読している getSnapshot は呼ばれないのでパフォーマンスの向上につながります。
同時に購読しているコンポーネントの数を減らす
巨大なフォームだとどうしてもフォーム自体の値(=ストア)は巨大なものになってしまいがちです。
この場合はこちらの解決策になります。
フォームの例でいえば、1画面内に表示するコントロールの数を何らかのカテゴリごとに分割するとか、あるいは仮想化などがあります。
おわりに
useSyncExternalStore 自体は素晴らしいフックですが、思わぬところでパフォーマンス上の問題を引き起こす可能性のあるフックでもあることがわかりました。
再レンダリングを最適化するための関数が逆にボトルネックになってしまうことから、その根本の仕組みを理解した上で利用することが重要なんだと改めて認識するいい機会でした。
ここまで読んでいただきありがとうございました。
Discussion