2024年7月22日
useDeferredValueを使用してUIを素早く最適化する
本記事は、原著者の許諾のもとに翻訳・掲載しております。
登場以来、Reactはアプリケーションのパフォーマンスを最適化するためのツールを多数供してきました。中には極めて有益でありながら、あまり知られていないものもあります。useDeferredValue
はその一つです。このツールは、特定の状況においてユーザーエクスペリエンスを大きく左右することができます。⚡
筆者は最近このフックを使用し、このブログの厄介なパフォーマンス問題を解決したのですが、そのあまりの効果に衝撃を受けました。低性能デバイスでは反則級の改善が見られ、まるで黒魔術のようでした。
useDeferredValue
には若干気後れさせるような評判があり、実際かなり洗練されたツールではあるのですが、正しいメンタルモデルで向き合えば恐るるに足りません。このチュートリアルでは、その仕組みと、アプリケーションのパフォーマンスを劇的に改善させる使い方を詳しく説明します。
問題
数年前、筆者は本物のような影を生成するShadow Palette Generatorというツールをリリースしました。
スライダーなどのUIコントロールを動かしてみることで、自分だけの影をデザインすることができます。CSSコードを自分のアプリケーションにコピペすることも可能です。
問題は次の点です。このUI上のコントロールは、「即座に」結果が反映されるよう設計されています。例えば、ユーザーが「Oomph」スライダーを動かすと、その効果がすぐに見られます。つまり、スライダーを動かす間、UIが「1秒間に数十回再レンダリングされる」ということです。
Reactは高速ですし、このUIの大部分は容易にアップデート可能です。問題は、シンタックスハイライトされた下部のコードスニペットです。
シンタックスハイライトの処理は驚くほど複雑です。まず、rawコードを「トークン化」する必要があります。この工程では、コードをいくつかに分けてラベルを付けます。各トークンには異なる色を付けられるため、それぞれ<span>
タグでラップする必要があります。
このスニペットの中の1行に対して必要なマークアップの量は以下の通りです。
最適化していない状態だと、Reactはこれだけのマークアップを 1秒間に何十回も再計算する必要があります。ほとんどのデバイスでは、ブラウザの処理が追いつかず、画面がカクカクします。
change
イベントは1秒間に最大60回発生しますが、UIが1秒間に処理できる更新の数はわずかです。UIの質が低く、反応が悪いと感じるのはそのためです。
これは興味深い問題です。このUIで最も重要な部分は、影の外観を示した左側の図です。この部分は、ユーザーが設定変更の影響を把握できるよう、変更が行われたら直ちに更新することが望まれます。また、コントロール自体も反応良くスムーズに動かないといけません。
一方、コードスニペットは1秒間に何十回も更新する必要はありません。ユーザーにとって重要なのは、自分のアプリケーションにコピーする最終的なコードだけです。変更を加えるたびに再計算を行うと、ユーザーエクスペリエンス全体が損なわれてしまいます。 言い換えると、このUIには優先度の高い領域(High Priority)と低い領域(Low Priority)があるということです。
優先度の高い領域はリアルタイムに、できるだけ迅速に更新し、優先度の低い領域は後回しにします。
不完全なソリューション
この問題を解決するために、私は当初「スロットリング」という手法を用いました。具体的に言うと、このコンポーネントに対して200ミリ秒ごとにしか再レンダリングできないように制限をかけたのです。 その結果がこちらです。
UIの他の部分に比べてコードスニペットの更新頻度が低いことに気づいたでしょうか。UIの他の部分は必要に応じて何度でも再レンダリングできますが、この部分は200ミリ秒に1回、つまり1秒間に5回しか更新されません。
この方法でも問題は改善しますが、完全なソリューションと言うには程遠い結果です。
まだ少し遅く、低品質に感じます。UIの一部を意図的に遅くしていることはユーザーには分からないので。
もっと重要な点は、超高性能な最新のコンピューターや低価格の古いAndroidスマホなど、人によって使用するデバイスはさまざまだということです。ユーザーのデバイスが十分高速なら、スロットリングは不要であり、意味もなく処理速度を下げているだけです。一方、デバイスが本当に遅ければ、200ミリ秒でさえ十分ではない可能性があり、そうするとUIの重要な部分がスムーズに表示されません。
こうした問題の解決に有効なのが、useDeferredValueです。
useDeferredValueの紹介
useDeferredValue
は、UIを優先度の高い領域と低い領域に切り分けられるReactフックです。何か重要なことが起きると、Reactが処理を中断できるようにします。
仕組みを理解するために、簡単な例から見ていきましょう。次のコードをご覧ください。
function App() {
const [count, setCount] = React.useState(0);
return (
<>
<ImportantStuff count={count} />
<SlowStuff count={count} />
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</>
);
}
ここでのstateはcount
です。これは、ボタンをクリックするごとに増えていく数字です。ImportantStuff
はUIの中で優先度の高い部分を表します。つまり、count
が増えるたびに直ちに更新したい部分です。SlowStuff
は、UIの中で重要性が低い部分を表します。
ユーザーがボタンをクリックしてcount
が増えるたびに、ReactはUIを更新する前にこれらの子コンポーネントを両方とも再レンダリングする必要があります。
詳しく分析してみましょう。下のボタンをクリックして、実際にレンダリングが行われる様子をご覧ください。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
このデモのUIは、録画されたインタラクションの動画です。タイムラインのスライダーを動かすと、その時点のUIの状態を確認することができます。ボタンをクリックするとレンダリングが始まりますが、レンダリングが完了するまでUIが更新されない点に注目してください。
ImportantStuff
とSlowStuff
の両方のレンダリングが、Reactが行う必要のある処理の全体です。次のスナップショットをクリックまたはタップして、中をのぞいてみてください。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
この仮想的な例では、ImportantStuff
は極めて高速でレンダリングされ、ほとんどの時間はSlowStuff
のレンダリングに費やされます。
ユーザーがボタンをクリックする間隔が短すぎると、Reactが処理を完了する前に次の更新が行われるため、レンダリングが積み重なってしまいます。そうすると、UIが低品質に感じられます。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
最初のレンダリング(count: 1
)が完了する前にユーザーが再度ボタンをクリックし、count
が2
になります。Reactは最初のレンダリングを放棄し、正しいcount
値で新しいレンダリングを開始します。UIは、レンダリングが正常に完了して初めて更新されます。
これらを踏まえた上で、useDeferredValue
を使ってどのように問題を解決できるのか見てみましょう。
以下がコードです。
function App() {
const [count, setCount] = React.useState(0);
const deferredCount = React.useDeferredValue(count);
return (
<>
<ImportantStuff count={count} />
<SlowStuff count={deferredCount} />
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</>
);
}
最初のレンダリングでは、count
とdeferredCount
の値はどちらも(0
)です。しかし、ユーザーが「Increment」ボタンをクリックすると興味深い現象が起こります。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
今度は、レンダリングごとにcount
の値と、count
とdeferredCount
の値のみ、線で区切られて表示されます。
info
試してみよう
何が起きているのかは後ほど詳しく説明します。まずは自分で少し触ってみてください。Reactがどのような処理を行っていて、それがなぜ有益なのか分かりますか?
タイムラインは友達です。黄色いスライダーをクリックするか押さえてドラッグすることで、動画を前後に進めることができます。もしくは、タイムラインを選択し、左右の矢印キーで1フレームずつ動かすこともできます。
では詳しく見てみましょう。count
stateが変わると、App
コンポーネントは直ちに再レンダリングを行います。count
は1
になりますが、興味深いことに、deferredCount
は変わっておらず、0
のままです。
これはつまり、SlowStuff
に渡されたpropsが前回のレンダリング時と全く同じだということです。React.memo()
によってメモ化されている場合、Reactは何を生成するかすでに分かっているため、わざわざ再レンダリングすることなく、最初のレンダリング時のデータを再利用します。
そのレンダリングが完了すると、すぐに2回目の再レンダリングが始まりますが、今度はdeferredCount
が更新されており、count
の値と同じ1になっています。これは、今回はSlowStuff
の再レンダリングが行われることを意味します。ここまでの処理が完了すると、UIが完全に更新されます。
そこまでする必要はあるのでしょうか?ここまで読んで、不必要に複雑だと感じている読者もいるかもしれません。結果は前と同じなのに、処理が増えているのではないかと。
この仕組みが非常に優れている理由を説明しましょう。再度stateが変わり、Reactの処理が中断された場合、重要な情報はすでに更新されています。Reactは、重要性の低い2回目のレンダリングを放棄し、より重要な部分のレンダリングを直ちに開始することができます。
言葉で説明するのは難しいですが、次の録画を見ていただくと違いが分かるのではないでしょうか。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
前に見た動画と同じように、ユーザーがクリックする間隔が短すぎて、Reactは全ての情報を更新することができていません。しかし、再レンダリングごとに優先度の高い部分と低い部分が分かれているため、Reactはクリック間に重要な部分を更新することができているのです。追加でクリックが行われると、Reactは進行中のレンダリングを放棄しますが、優先度が低い情報のため問題ありません。
これはなかなか難しい問題です。少し難しすぎると感じているようでしたら、次のセクションで基本的な仕組みを説明しているので、こちらを読んでいただくといいでしょう。
落とし穴:メモ化が必要
一つ重要な点を述べます。useDeferredValue
は、優先度の低い、レンダリングの遅いコンポーネントがReact.memo()
でラップされている場合のみ機能します。
import React from 'react';
function SlowComponent({ count }) {
// Component stuff here
}
export default React.memo(SlowComponent);
React.memo()
はReactに対し、propsまたはstateが変わった場合のみこのコンポーネントを再レンダリングするよう指示します。React.memo()
がなければ、countのpropsが変わったかどうかにかかわらず、SlowComponent
は親コンポーネントが再レンダリングされるたびに再レンダリングされます。
これは本当に重要なことなので、きちんと理解する必要があります。もう少し深掘りしてみましょう。繰り返しになりますが、以下がコードです。
function App() {
const [count, setCount] = React.useState(0);
const deferredCount = React.useDeferredValue(count);
return (
<>
<ImportantStuff count={count} />
<SlowStuff count={deferredCount} />
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</>
);
}
ユーザーが初めてボタンをクリックすると、count
stateが0
から1
に増えます。Appコンポーネントは再レンダリングしますが、useDeferredValue
フックは前回の値を再利用します。deferredCount
は1
ではなく0
に設定されます。
デフォルトでは、Reactはpropsが変わったかどうかにかかわらず、全ての子コンポーネントを再レンダリングします。React.memo()
が設定されていなければ、ImportantStuff
もSlowStuff
も再レンダリングされるため、useDeferredValue
を使用するメリットが得られません。
SlowStuff
をReact.memo()
でラップすると、Reactは現在のpropsを前回のものと比較し、再レンダリングが必要かどうかを判断します。deferredCount
が0
のままなので、Reactは「新しい情報はないようだ。UIのこの部分を再計算する必要はない」と判断します。
これは筆者にとって目からうろこでした。useDeferredValue
は、UIの中で優先度が低い部分のレンダリングを(退屈な宿題を先延ばしにするように)後回しにすることを可能にします。最終的にはレンダリングが行われ、UIは全て更新されますが、一旦保留にするということです。stateが変わるたびに、Reactはレンダリングを中断し、より重要な情報のレンダリングに注力するのです。
info
Reactのレンダリングについて考える上でのメンタルモデル
Reactのレンダリングの仕組みについて、かなり熟知している前提で話していることは分かっています。頭がクラクラしている読者には、筆者による次のブログ記事が大いに役立つと思います。
- “Why React Re-Renders”(「Reactはなぜ再レンダリングするのか」)
実に多くのReact開発者がReactのレンダリングの仕組みを誤解しているので、この記事を読んで誤解を解き、useDeferredValue
を理解する上で必要な知識を得ていただければと思います。
落とし穴:複数のstate変数を扱う
ここまでは、countのような単一のプリミティブ値を扱う場合におけるuseDeferredValue
の仕組みについて見てきましたが、現実の世界では物事がそこまで単純なことはほとんどありません。
筆者が開発した「Shadow Palette Generator」では、関連するstateがいくつかあります。
function ShadowPaletteGenerator() {
const [oomph, setOomph] = React.useState(0.5);
const [crispy, setCrispy] = React.useState(0.5);
const [backgroundColor, setBackgroundColor] = React.useState('#F00')
const [tint, setTint] = React.useState(true);
const [resolution, setResolution] = React.useState(0.75);
const [lightPosition, setLightPosition] = React.useState({
x: -0.2,
y: -0.5,
});
const cssCode = generateShadows(oomph, crispy, backgroundColor, tint, resolution, lightPosition);
return (
<>
{/* Other stuff omitted for brevity */}
<CodeSnippet lang="css" code={cssCode} />
</>
);
}
最初は、それぞれのstateについて遅延させた値を作成しなくてはならないと考えていました。
const deferredOomph = React.useDeferredValue(oomph);
const deferredCrispy = React.useDeferredValue(crispy);
const deferredBg = React.useDeferredValue(backgroundColor);
const deferredTint = React.useDeferredValue(tint);
const deferredResolution = React.useDeferredValue(resolution);
const deferredLight = React.useDeferredValue(lightPosition);
そうすることもできますが、もっと簡単な方法があります。レンダリング時に生成されるCSSコードである計算値を遅延させるのです。
const cssCode = generateShadows(oomph, crispy, backgroundColor, tint, resolution, lightPosition);
const deferredCssCode = React.useDeferredValue(cssCode);
return (
<>
{/* Other stuff omitted for brevity */}
<CodeSnippet lang="css" code={deferredCssCode} />
</>
);
このフックはuseDeferredState
ではなく、useDeferredValue
というものです。useDeferredValue() に渡す引数がstate変数でなくてはならないというルールはないのです!
基本的な仕組みを理解するのが非常に重要である理由はここにあります。重要なのは、優先度の高い部分のレンダリングを行っている際に、優先度の低いコンポーネント(この場合はCodeSnippet
)のpropsに新しい値が渡されないようにすることです。
ローディングアイコンの表示
再計算が進行中であり、UIの一部の情報が古いことをユーザーに知らせたい場合があるかもしれません。
例えば、次のような表示を使用することもできます。
<SlowStuff>
の更新が反映されるまでの間、その部分の画面を半透明にし、スピナーを表示します。そうすることで、ユーザーはUIの再計算が進行中であると分かります。
では、どうすればUIの一部がまだ更新されていないと分かるのでしょうか?実は、それを判別するためのツールがすでにあるのです。
以下がコードです。
function App() {
const [count, setCount] = React.useState(0);
const deferredCount = React.useDeferredValue(count);
const isBusyRecalculating = count !== deferredCount;
return (
<>
<ImportantStuff count={count} />
<SlowWrapper
style={{ opacity: isBusyRecalculating ? 0.5 : 1 }}
>
<SlowStuff count={deferredCount} />
{isBusyRecalculating && <Spinner />}
</SlowWrapper>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</>
);
}
UIの情報が古いかどうかは、count
とdeferredCount
を比較することで分かります。
筆者が最初にこれを見たときは、あまりに単純すぎて疑わしいと思いました。しかし、よく考えてみると理にかなっていたのです。
- 優先度の高い最初のレンダリングで、
deferredCount
は前回の値を再利用します。count
は1
に更新されますが、deferredCount
は0
のままです。両者の値は異なります。 - 次に行われる優先度の低いレンダリングでは、
deferredCount
が最新の値である1
に更新されます。count
とdeferredCount
の両方が同じ値になります。
最初のレンダリング時に<SlowStuff>
のレンダリングを後回しにすることを可能にするのと同じ仕組みにより、UIがまだ完全に同期されていないことを見分けられるのです。実に秀逸ではないでしょうか。
実際にこれを行いたいかどうかは別問題です。筆者のShadow Palette Generatorで試してみました。
個人的には、この場合は改善しているとは言えないと思います。ユーザーには影の図に注目してもらいたいのに、コードスニペットの方に目が行ってしまいます。 しかし、場合によってはUIの一部の情報が古いことをユーザーに知らせる方法として役立つかもしれません。
最初のレンダリングを高速化する
数週間前にReact 19がベータ版に移行しました。間もなく公開されるこのメジャーアップデートでは、さまざまな変更が行われており、useDeferredValue
も大幅にパワーアップされる予定です。
React 19以前では、useDeferredValue
は与えられた値で初期化されていました。
function App() {
const [count, setCount] = React.useState(0);
const deferredCount = React.useDeferredValue(count);
// On the initial render:
console.log(deferredCount); // 0
console.log(count === deferredCount); // true
}
Reactには使用可能な前回値がないため、ここで説明したような二重レンダリングは行いません。したがって、実質的にuseDeferredValue
は最初のレンダリングに対しては効果がないのです。
しかし、React 19からは初期値を指定できるようになります。
const deferredCount = React.useDeferredValue(count, initialValue);
なぜそうする必要があるのでしょうか?このパターンでは、最初のレンダリングを高速化できる可能性があります。
例えば、Shadow Palette Generatorを使用して次のようなことができます。
const cssCode = generateShadows(oomph, crispy, backgroundColor, tint, resolution, lightPosition);
const deferredCssCode = React.useDeferredValue(
cssCode,
null
);
return (
<>
{/* Other stuff omitted for brevity */}
{deferredCssCode !== null && (
<CodeSnippet lang="css" code={deferredCssCode} />
)}
</>
);
優先度の高い高速レンダリングでは、deferredCssCode
はnull
となるため、<CodeSnippet>
はレンダリングすらされません。しかし、高速レンダリングが終わると、すぐにこのコンポーネントの再レンダリングが自動的に行われ、枠にコードが表示されます。
重要度の低いUI要素を待つ必要がないため、アプリケーション全体としてはレスポンスが向上するはずです。
none
Reactのドキュメント
このチュートリアルでは、useDeferredValueの主なユースケースの一つについて説明しましたが、Suspenseに対応したデータ取得ライブラリを使用する場合など、他にも役立つ場面があるでしょう。詳しくはReactの公式ドキュメントをご覧ください。
劇的な変化
それでは、useDeferredValue
フックを使用した場合の結果を見てみましょう。
最高です!何もかも非常に滑らかです。💯
でもちょっと待ってください。筆者がテストに使用しているのは高性能なMacBook Proです。より性能の低いデバイスではどうでしょうか?
数年前、近所のPCショップで一番安い新品のWindowsノートパソコンを買いたいと言ったところ、インテルCeleronプロセッサーを搭載した110米ドルのAcer製ノートパソコンを引っ張り出してきてくれました。このマシンを使用し、useDeferredValue
を実装した状態で動かすとこうなります。
さっきほど滑らかではありませんが、スタートメニューを開くのさえ時間がかかるマシンにしては悪くありません。コントロールの操作を終えるまでコードスニペットが更新されない点に注目してください。ここではuseDeferredValue
が大いに役立っています。
Reactの多くの点について言えることですが、適切なメンタルモデルで臨まなければuseDeferredValue
は非常に複雑に感じられます。Reactは非常に洗練されたツールへと進化を遂げてきましたが、効果的に使いこなすためには、その仕組みを直感的に理解できるようになる必要があります。
筆者は2年近くかけてReactについて学べる究極のリソースを作り上げました。その名もThe Joy of Reactです。仕事で10年近くReactを使ってきた経験から得た知識を全て網羅しています。
もしこのブログ記事が役に立ったと感じていただけたなら、筆者のコースから得られるものは多いと思います。このコースは、多くの気づきが得られ、Reactの仕組みに関する強力なメンタルモデルを形成でき、Reactを使用して豊かでダイナミックなWebアプリケーションを構築する方法を学べる内容となっています。
コースに関する詳細は以下をご覧ください。
最後まで読んでいただきありがとうございました! 💖
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa