実務でReact製フォームの初期レンダリングを約6倍速くしました(そこからさらに2倍速くできる方法も見つけました)。
tl;dr
- 🐛 業務で既存のReactアプリの最適化に携わったよ。
- 🐌 その過程で、露骨にレンダリングが遅いフォームを見つけたよ。
- 🔎 原因は選択肢が90個以上もある選択欄がフォーム内に複数あったからだよ。
- 🫥 仮想リストを実装して最初にレンダリングする要素を少なくしたら、うまく行ったよ。
- 🤔 おまけ:個人の調査の結果、選択肢が90個あることより、使用したパッケージ側に問題があるっぽいよ。
背景
数ヶ月前、私は業務の一環として、開発したWebアプリのパフォーマンスの不備、バグが無いかを調査し、必要に応じて修正することになりました。
パフォーマンスの測定として、Aiden Baiさんが開発したツール"React Scan"を使って、大きなFPSドロップが存在しないかを確かめました。
問題の箇所
以下は今回修正を対応したフォームの例です。
曜日ごとに二つの選択欄があり、時刻を選択するための96個の項目がございます。
export const ITEMS = [
{
"label": "00時00分",
"value": "00:00"
},
{
"label": "00時15分",
"value": "00:15"
},
{
"label": "00時30分",
"value": "00:30"
},
{
"label": "00時45分",
"value": "00:45"
},
{
"label": "01時00分",
"value": "01:00"
},
{
"label": "01時15分",
"value": "01:15"
},
// ...
{
"label": "23時45分",
"value": "23:45"
}
] // Array(96)
問題
フォームの初期表示がとても遅い!!
フォームの初期表示に平均600msほどかかり、微妙なカクツキを視認できます。これは良くない。
原因
選択項目が90項目以上あるRadix UI製の選択欄が原因でした。それが同時に6つほどレンダリングされることで、初期表示が遅くなってしまったようです。
function SlowSelect() {
return (
<Select.Root>
<Select.Trigger className="border border-neutral-300 rounded bg-neutral-10 flex h-11 px-3 items-center w-min min-w-48 justify-between">
<Select.Value className="whitespace-nowrap" />
<Select.Icon className="">
<ArrowDownIcon className="size-5" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
position="popper"
className="bg-neutral-100 border w-[var(--radix-select-trigger-width)] max-h-[var(--radix-select-content-available-height)] border-neutral-300 rounded"
sideOffset={8}
>
<Select.Viewport>
{items.map(({ label, value }) => (
<Select.Item
key={label}
value={value}
className="h-11 pl-10 pr-3 flex items-center outline-0 data-[highlighted]:bg-violet-300"
>
<Select.ItemIndicator className="absolute left-4">
<CheckIcon className="size-5" />
</Select.ItemIndicator>
<Select.ItemText className="">{label}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
今回の解決策
今回はReact Windowを使った仮想リストを実装することで、初期レンダリングのコストを抑えることにしました。
function FastSelectBox() {
const [value, setValue] = useState<string | null>(null);
return (
<Select.Root onValueChange={setValue}>
<Select.Trigger className="border border-neutral-300 rounded bg-neutral-10 flex h-11 px-3 items-center w-48 justify-between">
<Select.Value className="whitespace-nowrap">
{value && items.find((item) => item.value === value)?.label}
</Select.Value>
<Select.Icon className="">
<ArrowDownIcon className="size-5" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
position="popper"
className="bg-neutral-100 border w-[var(--radix-select-trigger-width)] max-h-[var(--radix-select-content-available-height)] border-neutral-300 rounded"
sideOffset={8}
>
<Select.Viewport>
<FixedSizeList
height={44 * 10}
itemCount={items.length}
itemSize={44}
width="100%"
>
{({ index, style }) => (
<Select.Item
value={String(items[index].value)}
style={style}
className="h-11 pl-10 box-border pr-3 flex items-center outline-0 data-[highlighted]:bg-violet-300"
>
<Select.ItemIndicator className="absolute left-4">
<CheckIcon className="size-5" />
</Select.ItemIndicator>
<Select.ItemText>{items[index].label}</Select.ItemText>
</Select.Item>
)}
</FixedSizeList>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
こちらで、もう一度フォームをレンダリングさせてみましょう。
グッと速くなりました!
仮想リストを使うことで初期レンダリングのコストが減り、おおよそ103秒ほどに下げられ、最初の例に比べて約6倍高速になりました!
おまけ:もう一つの解決策
個人の調査の結果、Radix UIと似たパッケージであるBase UIを使って再実装してみました。
その結果、仮想リストを用いなくても高速にレンダリングできることが分かりました!初期レンダリングが平均70-50msに抑えられ、これは仮想リストを導入したRadix UIのケースよりも2倍高速であり、最初の例に比べて約10倍高速です。
さらに、Base UIはRadix UIより頻繁にメンテナンスされています。
上にある画像は、mui/base-ui
(Base UI)のコントリビューションに関するチャートです。2025年の2月から一か月ごとに100回以上コミットをしていることから、今日も頻繁にメンテナンスされているとみられています。
こちらは、radix-ui/primitives
(Radix UI)のコントリビューションに関するチャートです。80回以上コミットした月はあるものの、コミットの頻度には月毎に大きな村があり、2025年の5月以降は20回未満の頻度なため、今日はあまりメンテナンスされていない印象です。
Base UIは、比較的クラシックなReactコンポーネントライブラリである、MUIの開発チームによってメンテナンスされているため、信頼性も期待できます。
2025年9月1日現在、安定版ははリリースされていないものの、個人で使う場合はこちらの方が良さそうですね。
今回の例のリポジトリ
こちらから今回のフォームの最適化に関するコードをまとめました。よかったらご覧ください!
Discussion