useDeferredValue и transitions: приоритет — это второй рендер
useDeferredValue срочно рендерит со старым значением, затем запускает прерываемый фоновый рендер с новым — два рендера по замыслу. Без memo ниже по дереву отсрочка ничего не даёт. В отличие от debounce нет фиксированной задержки: темп задаёт скорость устройства.
Во внутреннем админ-инструменте был поиск по клиентскому индексу на 12 000 строк, и набор текста ронял кадры: каждое нажатие запускало 80-миллисекундный рендер отфильтрованной таблицы, так что INP на этом поле держался около 350 мс — глубоко в зоне «плохо». Инженер прочитал про конкурентный рендеринг, удалил старый debounce на 250 мс и обернул запрос в useDeferredValue. Поле осталось ровно таким же дёрганым. Историю рассказал Profiler: каждое нажатие теперь давало два рендера родителя — и оба перерисовывали 80-миллисекундную таблицу, потому что компонент таблицы не был мемоизирован. useDeferredValue сделал ровно то, что обещает: сначала срочный ререндер со старым запросом, затем фоновый с новым. Но срочный рендер всё равно перерисовал таблицу — тот же старый запрос, нет memo, нет bailout — и «быстрый» проход тоже стоил 80 мс. Отсрочка работала; ничто ниже по дереву не давало ей окупиться. Одна обёртка memo() спустя — срочный рендер упал ниже 2 мс, INP опустился под 100 мс, а фоновый рендер тихо перезапускался каждый раз, когда его прерывало следующее нажатие. Урок, который команда записала: useDeferredValue — это не debounce, это второй рендер, и он что-то даёт, только если первый дёшев.
Честная механика: два рендера, а не один отложенный
Когда значение, переданное в useDeferredValue, меняется, React не ждёт, чтобы отрендерить один раз. Он делает вот что, по порядку:
- Срочный ререндер со СТАРЫМ отложенным значением. Компонент перерисовывается немедленно, чтобы срочная часть обновления — контролируемый input, отзывающийся на нажатие, — закоммитилась сразу.
useDeferredValue(query)в этом проходе всё ещё возвращает предыдущий запрос. - Фоновый ререндер с НОВЫМ значением. Затем React планирует второй рендер, где отложенное значение догоняет. Этот рендер идёт с приоритетом transition: он прерываем. Если посреди него прилетает ещё одно нажатие, React бросает недостроенную фоновую работу и перезапускает её с самым свежим значением — промежуточные значения пропускаются и никогда не коммитятся.
Итого одно нажатие стоит двух рендеров всего, что читает компонент с отложенным значением. Это осознанная цена. Выгода существует только тогда, когда срочный рендер почти бесплатен, — и тут возвращается контракт из урока про мемоизацию:
const deferredQuery = useDeferredValue(query);
// ❌ срочный рендер всё равно перерисовывает тяжёлую таблицу — отсрочка ничего не даёт
<HugeTable query={deferredQuery} />
// ✅ memo + неизменённый проп в срочном проходе = bailout; платит только фоновый рендер
const HugeTable = memo(function HugeTable({ query }) { /* 80ms of rows */ });В срочном проходе deferredQuery не изменился с прошлого рендера — поэтому мемоизированная HugeTable срезается через поверхностное сравнение props, и срочный рендер стоит микросекунды. Без memo срочный проход перерисовывает 80 мс таблицы со старым запросом — чистые потери. Это самый частый способ, которым useDeferredValue «не работает» в продакшен-коде.
Коллега оборачивает входные данные медленного графика в useDeferredValue, но Profiler показывает, что набор текста всё так же дёргается, причём теперь два медленных рендера на нажатие вместо одного. Каков диагноз?
Не debounce: без фиксированной задержки, адаптируется к устройству
Debounce и useDeferredValue лечат пересекающиеся симптомы разной механикой, и разница измерима:
- Debounce выбирает константу. 250 мс означает, что каждый пользователь ждёт 250 мс после последнего нажатия: на M3 MacBook, где рендер занимает 12 мс, вы навязали 238 мс искусственной задержки; на бюджетном Android, где рендер 400 мс, ожидание — 250 мс плюс блокирующий 400-миллисекундный рендер, замораживающий input посреди набора.
- У useDeferredValue нет таймера. Фоновый рендер стартует, как только React свободен. На быстрой машине он успевает между нажатиями — результаты ощущаются мгновенными, искусственной задержки нет. На медленном устройстве каждое нажатие прерывает ещё идущий фоновый рендер, React перезапускается с последним значением, и список просто отстаёт, пока input остаётся отзывчивым: лаг автоматически равен тому, что устройство может себе позволить, без констант для подбора.
Честная граница: отсрочка помогает только стоимости рендера. Она не умеет ограничивать частоту сетевых запросов — отложенное значение в итоге меняется раз на нажатие, и если ваш fetch завязан на него, сервер всё равно получает запрос на каждое нажатие (просто чуть позже). Debounce/throttle остаются правильным инструментом, когда дорогая вещь — сетевой вызов или нагрузка на сервер, и они сочетаются: дебаунсим запрос, откладываем рендер.
Lanes: что на самом деле значит «срочно»
Под капотом React планирует работу в lanes — корзинах приоритетов. Дискретные события ввода (keydown, click) рендерятся в синхронной полосе: они обязаны закоммититься до следующей отрисовки браузера, потому что контролируемый input, не отозвавшийся символом в следующем кадре, читается как сломанная клавиатура. Это и есть определение срочности — пользователь только что выразил намерение, и UI обязан подтвердить его сейчас. Transitions (startTransition, useTransition и фоновая половина useDeferredValue) рендерятся в transition-полосах: прерываемые, бросаемые, перезапускаемые.
Прерывание — это перезапуск с нуля, а не пауза с продолжением: когда срочное обновление прилетает посреди transition, React выбрасывает частично построенное дерево и рендерит заново со свежим состоянием. Эта работа теряется по замыслу — вот почему рендер-функции обязаны быть чистыми и без побочных эффектов: transition-рендер может выполниться и быть выброшенным сколько угодно раз, прежде чем один из них закоммитится. Это же означает, что длинный transition на медленном устройстве может быть заморен голодом быстрым машинистом: каждое нажатие убивает предыдущий фоновый рендер, и результаты появляются только в паузах набора — ровно тот UX, который и нужен: кадры тратятся на input, а не на список, который никто не читает посреди очереди нажатий.
Числа: почему это история про INP
INP — Interaction to Next Paint — оценивает худшую задержку между взаимодействием и следующим кадром; до 200 мс — «хорошо», выше 500 мс — «плохо», и это полевая метрика Core Web Vitals, измеряемая на реальных устройствах ваших пользователей. Синхронный 350-миллисекундный рендер списка внутри обработчика нажатия и есть INP 350+ мс на каждое нажатие — следующего кадра нет, пока рендер не закоммитится. Расщепление этой работы через useDeferredValue уносит 350 мс в прерываемый фоновый рендер и оставляет срочный коммит в единицах миллисекунд: INP падает до стоимости дешёвого прохода. Поэтому же «у меня на машине быстро» не работает как тест: ценность отсрочки максимальна ровно на тех устройствах, на которых вы не разрабатываете, где фоновый рендер прерывается десятки раз. Профилируйте с 6x-троттлингом CPU; после релиза смотрите полевой INP.
Поисковая строка шлёт API-запрос на каждую смену query, и серверная команда жалуется на объём запросов при наборе. Инженер предлагает заменить существующий debounce 300 мс на useDeferredValue «как современный эквивалент». Что не так?
useTransition против useDeferredValue: какая ручка
Машинерия одна, различие — во владении состоянием. useTransition — когда setState ваш: вы оборачиваете обновление (startTransition(() => setQuery(q))) и бесплатно получаете isPending. useDeferredValue — когда вы получаете значение, которым не управляете: prop, значение контекста, чужое состояние — и хотите отстающую копию для дорогого поддерева. Сравнение двух значений (query !== deferredQuery) даёт тот же pending-сигнал. Если вы откладываете значение, чей setState написали тремя строками выше, transition — более чистая запись того же намерения.
- 01Опишите точную последовательность рендеров после нажатия, когда query питает useDeferredValue, и назовите требование к низу дерева, без которого API — чистый убыток.
- 02Сравните useDeferredValue с debounce на быстром и медленном устройстве и объясните, почему выбор между ними определяется тем, что именно дорого.
useDeferredValue честно описывается одним предложением, которое удивляет большинство команд: он намеренно удваивает ваши рендеры. Первый рендер срочный и идёт со старым отложенным значением, чтобы input отозвался на нажатие до следующей отрисовки — вот что значит «срочно» в модели полос React: дискретный ввод коммитится синхронно, иначе UI читается как сломанный. Второй рендер идёт с приоритетом transition с новым значением, и он прерываем — следующее нажатие его бросает, работа выбрасывается, рендер перезапускается с последним значением; поэтому важна чистота рендера и поэтому промежуточные состояния никогда не показываются. Экономика сходится только тогда, когда срочный проход почти бесплатен, а для этого нужна мемоизация ниже по дереву: в срочном рендере отложенное значение не изменилось, и memo-обёртка позволяет дорогому поддереву срезаться; без неё вы платите полный рендер дважды — ловушка из инцидента с админкой, где INP стоял на 350 мс, пока один memo() не уронил срочный проход ниже 2 мс. Отличие от debounce механическое: debounce — фиксированный таймер, который переплачивает на быстрых устройствах и недоплачивает на медленных, а у отсрочки констант нет — она адаптируется, отставая ровно настолько, насколько требует устройство. Но отсрочка планирует рендеры, а не запросы: трафик на сервер она не уменьшит, эта работа остаётся за debounce, и они сочетаются. Берите useTransition, когда setState ваш и нужен isPending; useDeferredValue — когда значение приходит извне. Проверяйте в Profiler с троттлингом CPU, и пусть табло ведёт полевой INP — «хорошо» это меньше 200 мс. Теперь, когда вы видите поиск, который всё равно дёргается несмотря на useDeferredValue, — откройте Profiler первым делом: два медленных рендера на нажатие вместо одного быстрого и одного медленного означают, что нужна одна обёртка memo() на дорогое поддерево, а не другой API.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.