Transitions и useDeferredValue: медленный рендер делаем прерываемым, а не быстрым
Transition делит апдейты на срочные и фоновые: срочный коммит проходит первым, тяжёлый рендер прерывается каждым нажатием. useDeferredValue — то же для получаемых значений; с Suspense transition держит старый UI без вспышки fallback. Один гигантский компонент прервать нельзя.
Команда продуктового поиска читала про конкурентный React, поэтому, когда фильтр каталога начал терять нажатия — 280 мс рендера на символ по 20 000 карточек товаров, — фикс казался очевидным: обернуть обновление стейта в startTransition. Обернули setQuery(e.target.value) и зарелизили. Ничего не улучшилось. Хуже: теперь лагал сам инпут, потому что текст в поле управлялся тем же query, а transition вежливо понизил приоритет ровно того апдейта, который отзеркаливает нажатие пользователя. Сеньор команды заметил это на ревью неделей позже: transition не делает работу дешевле, он раскладывает её по полосам — а они затолкали единственную задачу срочной полосы в медленную. Фиксом стали два стейта: setText(value) остаётся срочным, чтобы инпут откликался мгновенно, а startTransition(() => setQuery(value)) уносит 280-миллисекундный рендер списка в фон, перезапускаемый каждым новым нажатием. Печать стала зеркально гладкой, результаты отставали на долю секунды — рендер в 280 мс никуда не делся, просто пользователь перестал платить за него своими нажатиями.
Две полосы: срочная и несрочная
startTransition ничего не ускоряет, не дебаунсит и не уносит работу с главного потока. Он меняет приоритет обновлений стейта внутри своего колбэка: они становятся transitions — несрочными апдейтами, которые React рендерит в фоне, пока экран показывает последний закоммиченный UI. Срочные апдейты (печать, клики, всё необёрнутое) прерывают этот фоновый рендер: React бросает наполовину построенное work-in-progress-дерево, сначала коммитит срочный апдейт, затем перезапускает рендер transition с самого свежего стейта. Во время фонового рендера React к тому же уступает главный поток между компонентами (примерно каждые 5 мс), так что браузер может рисовать и обрабатывать события посреди рендера, а не замирать на все 280 мс.
function CatalogSearch({ products }) {
const [text, setText] = useState(""); // срочно: отзеркаливает нажатие
const [query, setQuery] = useState(""); // transition: питает тяжёлый список
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setText(e.target.value); // коммитится немедленно
startTransition(() => {
setQuery(e.target.value); // рендерится фоном, прерываемо
});
}
return (
<>
<input value={text} onChange={handleChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ProductGrid products={products} query={query} />
</div>
</>
);
}Разделение — и есть вся конструкция: рендер срочного стейта крошечный (один инпут) и коммитится за кадр-два; дорогое дерево зависит только от transition-стейта, поэтому его рендер прерываем и перезапускаем. isPending даёт честный сигнал загрузки для устаревшего, но видимого контента. Первая попытка команды из истории провалилась потому, что один стейт делал обе работы — обёртка в transition сделала несрочным и эхо инпута. Эвристика: значение, которым пользователь манипулирует напрямую, никогда не должно жить внутри transition.
Управляемый поисковый инпут питает дорогой список результатов через одно значение стейта. Разработчик оборачивает весь setState в startTransition — и инпут начинает лагать. Что произошло?
useDeferredValue: откладываем значения, которыми не владеем
useTransition требует владеть вызовом setState. Часто это не так — проп приходит от родителя, роутера или библиотечного хука. useDeferredValue(value) — эквивалент для принимающей стороны: он возвращает предыдущее значение, пока фоновый ре-рендер с новым значением в полёте, позволяя держать дорогое поддерево на устаревшем значении, не трогая производителя.
function SearchResults({ query }) { // query обновляется срочно выше
const deferredQuery = useDeferredValue(query);
const isStale = deferredQuery !== query;
const grid = useMemo(
() => <HeavyGrid query={deferredQuery} />,
[deferredQuery]
);
return <div style={{ opacity: isStale ? 0.6 : 1 }}>{grid}</div>;
}Механика: когда query меняется, React сначала срочно ре-рендерит с deferredQuery, всё ещё держащим старое значение, — этот рендер обязан быть дешёвым, поэтому дорогое поддерево мемоизировано по deferredQuery и выходит по bail-out, — а затем запускает фоновый рендер, где deferredQuery догоняет. Этот фоновый рендер — transition: прерываемый, перезапускаемый при следующем изменении. Мемоизация здесь не декоративна: без неё срочный рендер всё равно перерендерит HeavyGrid со старым значением, и вы заплатите полную цену дважды за нажатие. Выбор между ними: владеете сеттером → useTransition (плюс isPending бесплатно); получаете значение → useDeferredValue (сравнивайте значения для определения устаревания).
Transitions и Suspense: без вспышки fallback
Вторая суперспособность — то, что transitions делают с Suspense. Срочный апдейт, который приостанавливается — скажем, смена вкладки заставляет компонент бросить промис за данными, которых нет, — прячет уже видимый контент за ближайшим fallback: страница, которую вы читали, заменяется спиннером. Тот же апдейт внутри transition ведёт себя иначе: React держит на экране текущий закоммиченный UI, рендерит приостановленное дерево в фоне и коммитит только когда данные готовы. Никакой вспышки fallback; старая вкладка остаётся видимой (затемните её через isPending). Поэтому навигации роутеров в современных фреймворках по умолчанию обёрнуты в transitions — клик по ссылке не должен превращать работающую страницу в спиннер. Исключение точное: защищён только уже показанный контент. Граница Suspense, монтирующаяся впервые внутри transition, всё равно покажет свой fallback — прежнего UI, который можно удержать, не существует.
Когда transitions не делают ничего
Transitions покупают прерываемость между компонентами и применяются к работе фазы рендера. Три ситуации их обезоруживают. Один гигантский компонент: React уступает поток между рендерами компонентов и никогда — внутри одного; монолитный график, строящий 10 000 SVG-элементов в одной функции за 200 мс, блокирует полосу при любом приоритете; разбейте его на мелкие компоненты или виртуализируйте. Цена не в рендере: если тормозит синхронный filter по 100k элементов прямо в обработчике события или layout thrash в эффектах — работа происходит вне фазы рендера, которую планируют transitions. Один дешёвый коммит: если апдейт был 20-миллисекундным рендером с одним коммитом, резать нечего — обёртка не меняет ничего наблюдаемого. Transition — это подсказка планировщику, а не ускорение: суммарная работа CPU та же или чуть выше (брошенные рендеры оплачены и выброшены — такова сделка: потраченные ватты в обмен на отзывчивый ввод).
Клик по вкладке подставляет компонент, который приостанавливается на время загрузки. Без transition вся страница заменяется спиннером; внутри startTransition — нет. Что именно меняет transition?
Расставьте шаги по порядку, чтобы сделать медленный рендер списка в 280 мс неблокирующим с помощью startTransition:
- 1 Разделить на две переменные состояния: одна для текста инпута (срочная), другая для query, питающего тяжёлый список (transition) — чтобы эхо и рендер шли разными полосами
- 2 В обработчике изменений вызвать setText(value) напрямую, чтобы эхо инпута коммитилось за кадр и UI оставался мгновенно отзывчивым
- 3 Обернуть setQuery(value) в startTransition(() => ...), чтобы пометить рендер списка несрочным и прерываемым последующими нажатиями
- 4 Считать isPending из useTransition и добавить визуальный сигнал (например opacity: 0.6) к устаревшему списку, чтобы пользователи знали, что результаты обновляются
- 5 Убедиться в Profiler, что нажатия клавиш больше не блокируют срочную полосу — рендер списка по-прежнему 280 мс, но инпут больше не тормозит
- 01Разберите по кадрам, что делает React, когда пользователь быстро печатает два символа в инпут, чей обработчик делает setText срочно и setQuery внутри startTransition.
- 02Назовите три ситуации, в которых обёртка апдейта в startTransition ничего не меняет, и каков настоящий фикс в каждой.
Ответ конкурентного React на дорогие апдейты — не делать их быстрее, а делать прерываемыми. startTransition помечает обновления стейта в своём колбэке несрочными: React рендерит их в фоне в work-in-progress-дерево, уступает главный поток между компонентами примерно каждые 5 мс, позволяет любому срочному апдейту прервать себя — бросая недостроенное дерево и перезапускаясь с новейшего стейта — и коммитит только готовый результат. Экран всегда показывает последний закоммиченный UI, а isPending сообщает о зазоре. Кардинальная ошибка — обернуть значение, которым пользователь манипулирует напрямую: управляемый инпут на transition-стейте отзеркаливает нажатия с фоновым приоритетом — тот самый лаг, который зарелизила команда каталога. Разделяйте стейт: срочный для эха, transition для тяжёлого дерева. Когда меняющееся значение приходит пропом, а не вашим setState, useDeferredValue — зеркало для принимающей стороны: он удерживает старое значение через дешёвый срочный рендер (мемоизируйте дорогое поддерево по отложенному значению, иначе трюк не даёт ничего) и догоняет в фоновом. С Suspense transitions убирают вспышку fallback: приостановившийся апдейт держит уже показанный контент на экране и коммитит по приходе данных — причина, по которой роутеры фреймворков оборачивают навигации в transitions, — хотя впервые монтирующиеся границы всё равно показывают fallback. И знайте пределы: уступки происходят между компонентами, так что монолитный компонент на 200 мс блокирует любую полосу; вычисления в обработчиках и эффекты выполняются вне планируемого рендера; единственный дешёвый коммит нарезать нечем. Суммарный CPU растёт, а не падает — брошенные рендеры суть цена ввода, который никогда не заикается. Теперь, когда увидишь лагающий инпут рядом с обновляющимся списком, первым делом потянешься за разделением стейта на два, а не за дебаунсом или useMemo.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.