ことりずかんは8羽です。8羽なら、どう書いても速い。
実務では、そうはいきません。商品が5000件、ログが10万行。同じコードのまま件数だけ増えたときに何が起きるかを見ておきます。
まず測る
やみくもに手を打つ前に、どこが遅いのかを確かめます。レンダリングの回と同じ手順です。
- React DevToolsのProfilerで記録して、どの部品が何回・何ミリ秒かかっているかを見る
- ブラウザの開発者ツールのPerformanceで、詰まっているのが描画なのか計算なのかを見る
原因は3つのどれかです。
| 症状 | 原因 | 手 |
|---|---|---|
| 最初の表示が遅い | 要素が多すぎる(DOMが重い) | 仮想化・ページ送り |
| 入力するたびにカクつく | 毎回の計算が重い | useMemo・デバウンス |
| 何をしても全体が描き直される | 不要な再描画 | memo・useCallback |
手その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,
})getNextPageParamがundefinedを返したら「もう無い」の合図です。あとは画面の下端に来たらfetchNextPage()を呼ぶだけです(IntersectionObserverか、それを包んだライブラリを使います)。
手その3:仮想化
画面に映っている行だけを作るやり方です。5000件あっても、DOMに置くのは十数個で済みます。
npm install --save-exact react-windowimport { 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>の数:175000行あるのに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: auto | CSSだけで画面外の描画を後回しにできる。仮想化より手軽 |
keyを正しく付ける | indexをkeyにしていると、並び替えのたびに作り直しになる |
memoやuseMemoが効く場所と効かない場所は、まずここで測っています公式ドキュメントも見てみる
TanStack外部サイトInfinite Queries | TanStack Query React DocsTanStack Query公式のuseInfiniteQuery。getNextPageParamの返し方と「もう無い」の伝え方がここに書かれています(英語)このレッスンのまとめ
- 測ってから手を打つ。Profilerで、遅いのがどこかを先に特定する
- いちばん素直なのはページ送り。ページ番号はURLに入れる
- 無限スクロールは
useInfiniteQuery。ただしフッター・戻る・キーボードの3つの弱点がある - 仮想化は画面に映る行だけを作る。5000行でもDOMは十数個で済む
- 仮想化では渡された
styleを必ず付ける(位置がそこに入っている) - 仮想化すると⌘Fが効かない。全部見せる必要のある画面には使わない
- 入力が重いならデバウンスか
useDeferredValue - 画像は
loading="lazy"とサイズ指定。順番としてはこれが先
次は、本番のエラーに気づく回です。公開したあとの話をして、この章を締めます。