Переходы и отложенные значения
useTransition помечает обновление состояния несрочным, чтобы ввод оставался отзывчивым; useDeferredValue отстаёт производным значением от ввода — оба держат UI быстрым под тяжёлыми рендерами и дают Suspense показывать устаревший контент вместо фолбэка.
Ты делаешь поле поиска над списком из 10 000 строк. Каждое нажатие клавиши ставит запрос, список перефильтровывается, новые строки рендерятся. На твоей машине всё нормально. На среднем ноутбуке пользователя печатать «engineer» — как печатать сквозь грязь: ввод отстаёт от пальцев на символ-другой, потому что каждое нажатие блокирует главный поток перерисовкой всего списка, прежде чем успеет появиться следующий символ.
Ничего не сломано. Фильтр корректен, список верный. Проблема в том, что React относится к двум обновлениям с очень разной срочностью — значению ввода (должно ощущаться мгновенно) и отфильтрованному списку (может прийти на такт позже) — как к одинаково срочным. Переходы — это то, как ты сообщаешь React разницу, чтобы срочное обновление выигрывало главный поток, а дорогое уступало.
После этого урока ты можешь пометить дорогое обновление состояния несрочным через useTransition, чтобы срочные обновления (печать) оставались отзывчивыми; использовать useDeferredValue, чтобы производное значение отставало от своего ввода, когда сеттер не твой; объяснить, как переходы дают Suspense показывать предыдущий контент вместо фолбэка, пока грузится следующий; и назвать режим отказа — оборачивание самого срочного обновления (значения ввода) в переход или обращение к любому из них, прежде чем ты измерил реальную проблему отзывчивости.
useTransition разбивает одно событие на два обновления: срочное, которое рендерится сразу, и несрочное, которое React может прервать. Ты вызываешь setQuery срочно, чтобы ввод сразу отразил нажатие, затем вызываешь дорогой setResults внутри startTransition. React рендерит срочное обновление первым; рендер перехода происходит в фоне и может быть выброшен, если придёт новое нажатие, поэтому он никогда не блокирует ввод.
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [results, setResults] = useState<Row[]>(allRows);
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const next = e.target.value;
setQuery(next); // urgent: the input must update now
startTransition(() => {
setResults(filterRows(allRows, next)); // non-urgent: can lag, can be dropped
});
}isPending равен true, пока рендер перехода в полёте — используй его, чтобы притушить устаревший список, но никогда чтобы блокировать печать.
useDeferredValue — та же идея, когда сеттер не твой: ты откладываешь значение, а не обновление. Дочерний компонент получает query пропсом и сам делает дорогую работу; ты не можешь обернуть его внутренний рендер в startTransition. Вместо этого передай ему отложенную копию значения. React держит ввод привязанным к живому query, но даёт отложенной копии отставать во время тяжёлых рендеров.
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* SlowList re-renders against deferredQuery, lagging behind input */}
<SlowList query={deferredQuery} dim={isStale} />
</>
);Правило большого пальца: владеешь сеттером на месте вызова → useTransition. Получаешь значение пропсом, который не можешь перехватить → useDeferredValue. Мемоизируй дорогой дочерний компонент (memo), чтобы отставание реально экономило рендеры.
Под Suspense переход говорит React держать предыдущий контент вместо сброса в фолбэк. Когда отложенное обновление читает данные, которые подвешиваются, обычное обновление размонтировало бы старый UI и показало фолбэк <Suspense> — список мигает в скелетон на каждое нажатие. Оборачивание обновления в переход (или управление им через useDeferredValue) заставляет React удержать последний хороший контент на экране и подменить новые результаты только когда они готовы. Ты получаешь устаревшее-во-время-загрузки бесплатно.
function Search() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Suspense fallback={<ResultsSkeleton />}>
{/* first paint shows the skeleton; later keystrokes keep the
previous results visible instead of re-blinking to skeleton */}
<Results query={deferredQuery} />
</Suspense>
</>
);
}Скелетон показывается один раз, при первой загрузке. После этого переходы превращают каждую следующую загрузку в мягкую подмену контента, а не во вспышку.
Режим отказа, определяющий паттерн: никогда не оборачивай срочное обновление в переход и никогда не тянись к любому из API до измерения. Если ты поместишь setQuery внутрь startTransition, само значение ввода станет прерываемым — печать начнёт отставать, ровно тот симптом, который ты пытался вылечить. Срочное обновление (то, чем пользователь манипулирует напрямую) должно оставаться снаружи. Второй отказ — преждевременность: список из 50 строк перерисовывается меньше чем за миллисекунду; добавление useTransition/useDeferredValue там не покупает ничего и добавляет концепцию устаревшего состояния, о которой надо думать. Эти API окупаются только когда дорогой рендер измеримо душит срочный.
// WRONG: the input value is urgent — this makes typing laggy
startTransition(() => {
setQuery(next); // urgent update demoted to non-urgent
setResults(filterRows(allRows, next));
});
// RIGHT: urgent stays urgent, only the expensive part is deferred
setQuery(next);
startTransition(() => setResults(filterRows(allRows, next)));Тянись к переходам, когда ты видел отставание ввода под тяжёлыми обновлениями, — а не как к обёртке по умолчанию вокруг каждого setState.
Поле поиска над большим списком — тормозящая версия, затем исправленная. Начни с наивной версии, где одно обновление делает всё:
function ProductSearch({ all }: { all: Product[] }) {
const [query, setQuery] = useState("");
// every keystroke filters 10k rows synchronously, blocking the input
const visible = useMemo(() => filterProducts(all, query), [all, query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductList items={visible} />
</>
);
}Фильтр мемоизирован, но он всё равно бежит на срочном пути: каждое нажатие должно завершить фильтр до того, как ввод перерисуется, поэтому печать застревает на машине пользователя. Теперь раздели срочности. Держи ввод привязанным к живому query, но рендери список против отложенной копии и мемоизируй список, чтобы отставание реально пропускало работу:
const ProductList = memo(function ProductList({ items }: { items: Product[] }) {
return <ul>{items.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
});
function ProductSearch({ all }: { all: Product[] }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// expensive filter now keys off the DEFERRED value, off the urgent path
const visible = useMemo(
() => filterProducts(all, deferredQuery),
[all, deferredQuery],
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<ProductList items={visible} />
</div>
</>
);
}Теперь ввод перерисовывается на каждое нажатие против query, пока тяжёлый фильтр и рендер списка отстают на deferredQuery. Флаг isStale притушивает список, чтобы пользователь видел, что он догоняет, — обратная связь без блокировки. Если бы список также загружал данные и подвешивался, оборачивание его в <Suspense> держало бы старые строки видимыми во время загрузки вместо мигания в скелетон. Ввод остаётся быстрым, потому что срочное обновление никогда не делит рендер с дорогим.
▸Почему это работает
Почему откладывание вообще помогает, если та же суммарная работа всё равно выполняется? Потому что оно меняет когда и выполняется ли она относительно ввода. Срочный рендер (ввод) коммитится первым и сразу, поэтому печать никогда не блокируется. Отложенный рендер бежит после, в фоне, и React может бросить отложенный рендер в полёте в тот же миг, как приходит более новое нажатие, — поэтому при быстрой печати промежуточные фильтры выбрасываются целиком, а не просто переупорядочиваются. Ты меняешь слегка устаревший список на всегда отзывчивый ввод, что и есть правильная сделка для поля поиска.
▸Частая ошибка
Тонкая ловушка: useDeferredValue и useTransition покупают отзывчивость, только если дорогой дочерний компонент реально пропускает перерисовку, когда отложенное значение не изменилось. Если ProductList не обёрнут в memo (или идентичность его пропсов меняется каждый рендер), он перерисовывается на срочном проходе всё равно, и откладывание не экономит ничего — ты добавил флаг устаревшести впустую. Всегда сочетай отложенное значение с мемоизированным потребителем и подтверди в React DevTools Profiler, что дорогое поддерево пропускает срочный рендер. Нет измеренного пропуска — нет выгоды.
Поле поиска над огромным списком печатает с тормозами. Коллега чинит это, оборачивая ОБА — setQuery (значение ввода) и setResults (отфильтрованный список) — внутрь одного startTransition. Список теперь обновляется плавно, но печать всё ещё ощущается тормозной. Почему, и какое разделение верное?
useTransition помечает обновление состояния несрочным: ты вызываешь срочный сеттер (значение ввода) сразу и снаружи него, затем оборачиваешь дорогой сеттер внутри startTransition, чтобы React рендерил его в фоне, мог прервать его и выбрасывал устаревшие во время быстрого ввода. useDeferredValue — та же сделка, когда у тебя только значение (пропс, сеттером которого ты не владеешь): передай дорогому дочернему компоненту отложенную копию, отстающую от живого значения, и мемоизируй этот компонент, чтобы отставание реально пропускало работу. Под Suspense оба дают React держать предыдущий контент на экране, пока грузится следующий, превращая вспышку фолбэка в мягкую подмену по принципу устаревшее-во-время-загрузки. Определяющий режим отказа — поместить срочное обновление (значение, которым пользователь манипулирует напрямую) внутрь перехода, что делает печать тормозной; второй — обратиться к любому из API до того, как ты измерил, что дорогой рендер душит срочный. Подбирай API под давление: владеешь сеттером → переход; получаешь значение → отложенное; и только когда отзывчивость — реальная, наблюдённая проблема.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.