入力欄に文字を打つたびに大量のリストを絞り込む画面などでは、1文字打つごとに重い再レンダリングが走り、入力がカクついて反応が悪くなることがあります。React 18 で追加された useTransition と useDeferredValue は、こうした場面で更新に優先順位を付け、入力のような緊急の更新を優先しつつ重い更新を後回しにするためのフックです。この記事では、2つのフックそれぞれの使い方、実行タイミングの考え方、そしてどちらを使うべきかの使い分けまで、初心者〜中級者向けに解説します。
目次
なぜ更新に優先順位が必要なのか
React では、状態が変わると関連する部分が再レンダリングされます。通常はこれで十分ですが、1回の入力で「入力欄の文字を更新する」軽い処理と「その文字で数千件のリストを絞り込んで描画し直す」重い処理が同時に走ると、重いほうに引きずられて入力欄の反応まで遅くなります。
useTransition と useDeferredValue は、この2種類の更新を「緊急(入力の反映)」と「緊急でない(重い絞り込み)」に分け、緊急なものを先に処理させることで、体感の反応を保ちます。緊急でない更新は中断・後回しが可能な更新として扱われます。
useTransition の使い方
useTransition は [isPending, startTransition] のペアを返します。startTransition で囲んだ状態更新は「緊急でない更新(トランジション)」として扱われ、入力などの緊急更新に処理を譲ります。isPending はそのトランジションが処理中かどうかを表す真偽値で、ローディング表示に使えます。
import { useState, useTransition } from "react";
function FilterList({ items }: { items: string[] }) {
const [text, setText] = useState("");
const [list, setList] = useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// 入力欄の更新は緊急:すぐ反映する
setText(value);
// 重い絞り込みは緊急でない更新として囲む
startTransition(() => {
setList(items.filter((item) => item.includes(value)));
});
}
return (
<div>
<input value={text} onChange={handleChange} />
{isPending && <p>絞り込み中...</p>}
<ul>
{list.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
setText は startTransition の外に置いているので、入力はすぐに反映されカクつきません。一方、重い setList は startTransition で囲むことで緊急でない更新になり、入力の処理が優先されます。isPending が true の間は「絞り込み中…」と表示でき、ユーザーに処理中であることを伝えられます。ポイントは、startTransition の中に入れるのは重い更新のほうで、入力欄自体の更新は外に置くことです。
useDeferredValue の使い方
useDeferredValue は、渡した値の「少し遅れて追従するコピー」を返すフックです。値の更新自体はそのまま行い、その値を使う重い部分にだけ遅延したコピーを渡すことで、重い再レンダリングを後回しにできます。startTransition のように更新処理を囲むのではなく、値そのものを遅延させるのが特徴です。
import { useState, useDeferredValue, useMemo } from "react";
function SearchBox({ items }: { items: string[] }) {
const [text, setText] = useState("");
// text より少し遅れて追従する値
const deferredText = useDeferredValue(text);
// 重い絞り込みは deferredText を使う
const list = useMemo(
() => items.filter((item) => item.includes(deferredText)),
[items, deferredText]
);
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ul>
{list.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
入力欄は text をそのまま使うので即座に反映されます。重い絞り込みは deferredText(少し遅れて追従する値)を使うため、入力の連打中は古い値のままリストを保ち、入力が落ち着いたところで最新の値に追いつきます。useMemo と組み合わせて、deferredText が変わったときだけ再計算するようにしているのもポイントです。
useTransition と useDeferredValue の使い分け
2つはよく似た目的で使いますが、「何を手元で制御できるか」で選ぶと分かりやすくなります。
| 観点 | useTransition | useDeferredValue |
|---|---|---|
| 制御するもの | 状態を更新する処理 | 受け取った値 |
| 向いている場面 | 自分で setState を呼ぶとき | propsなど外から来た値を遅延させたいとき |
| 待機中の表示 | isPending が使える | 現在値と遅延値を比べて自作する |
自分の中で状態更新を呼び出していて、その更新を緊急でないものとして扱いたいなら useTransition が向いています。一方、値だけを受け取っていて更新処理に手を出せない(propsで渡ってくる値など)場合は、その値を useDeferredValue で遅延させるのが自然です。どちらも「入力などの緊急更新を優先し、重い更新を後回しにする」という狙いは同じです。
使うときに気をつけること
入力欄の更新をトランジションに入れない
useTransition を使うとき、入力欄の値を更新する setState まで startTransition の中に入れてしまうと、入力自体が遅延して逆に反応が悪くなります。緊急でない(後回しにしてよい)のはあくまで重い更新のほうです。入力欄の反映は必ず startTransition の外に置きます。
そもそも更新が重くないと効果は出にくい
これらのフックは「重い再レンダリングがある」ことが前提の最適化です。数十件程度の軽いリストでは体感差はほとんど出ません。まずは本当に描画が重くて反応が悪いのかを確認し、必要なところにだけ導入するのが、コードを無駄に複雑にしないコツです。
まとめ
useTransition と useDeferredValue は、更新に優先順位を付けて、入力のような緊急の更新を優先しつつ重い更新を後回しにするためのフックです。useTransition は startTransition で重い状態更新を「緊急でない更新」として囲み、isPending で待機中を表示できます。useDeferredValue は値を少し遅れて追従させ、その遅延値を重い処理に渡すことで再レンダリングを後回しにします。自分で更新を呼ぶなら useTransition、値だけを遅らせたいなら useDeferredValue、という基準で選ぶとよいでしょう。大量のデータを絞り込む画面などで入力がカクつくときに活用してみてください。