React・本番で気をつけること

データが増えても重くならないように

8羽なら何をしても速いですが、5000件になると話が変わります。ページ送り・無限スクロール・仮想化の使い分けと、5000行でもDOMの要素が十数個で済む仕組みを実際に確かめます。

ことりずかんは8羽です。8羽なら、どう書いても速い

実務では、そうはいきません。商品が5000件、ログが10万行。同じコードのまま件数だけ増えたときに何が起きるかを見ておきます。

まず測る

やみくもに手を打つ前に、どこが遅いのかを確かめます。レンダリングの回と同じ手順です。

  1. React DevToolsのProfilerで記録して、どの部品が何回・何ミリ秒かかっているかを見る
  2. ブラウザの開発者ツールのPerformanceで、詰まっているのが描画なのか計算なのかを見る

原因は3つのどれかです。

症状原因
最初の表示が遅い要素が多すぎる(DOMが重い)仮想化・ページ送り
入力するたびにカクつく毎回の計算が重いuseMemo・デバウンス
何をしても全体が描き直される不要な再描画memouseCallback

手その1:ページ送り

いちばん素直な答えです。そもそも全部出さない

const [page, setPage] = useState(1)
const perPage = 20
const shownBirds = birds.slice((page - 1) * perPage, page * perPage)

サーバー側で切ってもらえるならそのほうが良く、TanStack QueryならqueryKeyにページ番号を入れます。

useQuery({ queryKey: ['birds', page], queryFn: () => fetchBirds(page) })

ページ番号をURLに入れておくと、共有・ブラウザバック・検索エンジンのすべてに効きます(/birds?page=3)。

手その2:無限スクロール

TanStack QueryのuseInfiniteQueryが担当します。

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ['birds'],
  queryFn: ({ pageParam }) => fetchBirds(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) =>
    lastPage.length === 0 ? undefined : allPages.length + 1,
})

getNextPageParamundefinedを返したら「もう無い」の合図です。あとは画面の下端に来たらfetchNextPage()を呼ぶだけです(IntersectionObserverか、それを包んだライブラリを使います)。

手その3:仮想化

画面に映っている行だけを作るやり方です。5000件あっても、DOMに置くのは十数個で済みます。

npm install --save-exact react-window
import { List } from 'react-window'
import type { RowComponentProps } from 'react-window'

function Row({ index, style, items }: RowComponentProps<{ items: Bird[] }>) {
  return <div style={style}>{items[index].name}</div>
}

<List
  rowComponent={Row}
  rowCount={birds.length}
  rowHeight={36}
  rowProps={{ items: birds }}
  style={{ height: 300 }}
/>

5000行を並べて、実際にDOMの<div>の数を数えてみました。

ページ全体の<div>の数:17

5000行あるのに17個。画面の外の行は作られておらず、スクロールすると使い回されて中身だけ入れかわりますstyleをそのまま渡さなければならないのは、この位置決めがstyleに入ってくるからです。

手その4:入力の間引き(デバウンス)

検索欄に1文字打つたびに、5000件の絞りこみや通信が走ると重くなります。

const [keyword, setKeyword] = useState('')
const [debounced, setDebounced] = useState('')

useEffect(() => {
  const timer = setTimeout(() => setDebounced(keyword), 300)
  return () => clearTimeout(timer)   // 入力が続いている間は毎回キャンセル
}, [keyword])

打ち終わって300ミリ秒たったら、その値を使うreturnの後片づけが効いている好例で、Effectの回で書いた形そのままです。

絞りこみ自体が重いならuseMemoで覚えさせます。

const shownBirds = useMemo(
  () => birds.filter((bird) => bird.name.includes(debounced)),
  [birds, debounced],
)

そのほか効くもの

効き方
画像の遅延読みこみ<img loading="lazy">。一覧に画像が並ぶならこれが最も効く
画像のサイズ指定width/heightを書く。読みこみ後のガタつきが消える
コード分割lazy + Suspenseで、開いたページのぶんだけ読む(Error Boundaryの回
content-visibility: autoCSSだけで画面外の描画を後回しにできる。仮想化より手軽
keyを正しく付けるindexkeyにしていると、並び替えのたびに作り直しになる
React再描画のしくみとmemo・useMemo・useCallbackレンダリングの回。memouseMemoが効く場所と効かない場所は、まずここで測っています

公式ドキュメントも見てみる

TanStack外部サイトInfinite Queries | TanStack Query React DocsTanStack Query公式のuseInfiniteQuerygetNextPageParamの返し方と「もう無い」の伝え方がここに書かれています(英語)

このレッスンのまとめ

  1. 測ってから手を打つ。Profilerで、遅いのがどこかを先に特定する
  2. いちばん素直なのはページ送り。ページ番号はURLに入れる
  3. 無限スクロールはuseInfiniteQuery。ただしフッター・戻る・キーボードの3つの弱点がある
  4. 仮想化は画面に映る行だけを作る。5000行でもDOMは十数個で済む
  5. 仮想化では渡されたstyleを必ず付ける(位置がそこに入っている)
  6. 仮想化すると⌘Fが効かない。全部見せる必要のある画面には使わない
  7. 入力が重いならデバウンスuseDeferredValue
  8. 画像はloading="lazy"とサイズ指定。順番としてはこれが先

次は、本番のエラーに気づく回です。公開したあとの話をして、この章を締めます。

やってみよう:5000羽にしてみる

件数を増やして、何が起きるかを見ます。

  1. birds.jsonの代わりに、5000件の配列をその場で作るコードに差しかえる
  2. そのまま表示して、最初の表示にどれくらいかかるかをProfilerで見る
  3. 検索欄に文字を打って、1文字ごとにどれくらい待たされるかを見る
  4. デバウンス(300ミリ秒)を入れて、3番がどう変わるかを見る
  5. npm install --save-exact react-windowして<List>に置きかえ、DOMの要素数を開発者ツールで数える
const birds = Array.from({ length: 5000 }, (_, i) => ({
  id: i,
  name: `ことり${i + 1}`,
  area: 'そのへん',
  emoji: '🐦',
  rare: 1,
  memo: '',
}))

2番と5番の差を自分の目で見ておくと、仮想化が何をしているのかが一度で分かります。確認が終わったら8羽に戻してください。

よくある質問

何件くらいから対策が必要ですか?
件数ではなく1件あたりの重さで決まります。単純な行なら数千件でも耐えますが、画像や複雑なカードなら数百件でつらくなります。まず開発者ツールで測って、遅い場所を特定してから手を打ちます。
仮想化とページ送りはどちらがよいですか?
スクロールで一気に見せたいなら仮想化、順番に見せて位置を共有したいならページ送りです。ページ送りはURLで状態が表せるので、検索エンジンや共有に強いという利点もあります。
無限スクロールの注意点はありますか?
フッターに永久にたどり着けなくなること、ブラウザバックで位置が戻らないこと、キーボード操作で先へ進みにくいことです。もっと見るボタンにするだけで、この3つはかなり緩和されます。
検索が1文字ごとに重いのですが。
入力のたびに絞りこみや通信が走っているためです。少し待ってからまとめて実行するデバウンスを挟むか、絞りこみ自体をuseMemoで覚えさせます。