Срочное и несрочное: useTransition и useDeferredValue

скорость≈ 12 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Поле ввода, под которым висит длинный отфильтрованный список, — классическое место, где React перестаёт поспевать за пальцами: каждая буква перерисовывает весь список, и браузер не успевает показать саму букву. Чинится это не ускорением списка, а фразой «это подождёт»: обновление помечается несрочным, и React разрешает себе отложить его ради того, что читатель видит прямо сейчас.

Разделы темы:·

Рукописный render() из «Рендера из состояния и делегирования» тормозил по причине, о которой там речи не было: пока он пересобирал разметку, страница стояла — браузеру просто нечем было заняться, поток был занят вашим кодом. React ничего в этом не изменил: его отрисовка — такой же синхронный код в том же единственном потоке.

Ниже кнопка добавляет букву в запрос, а список «стоит» двести миллисекунд — вместо честной вёрстки трёхсот строк поток просто занят. Прежде чем нажимать Run, скажите себе: через сколько миллисекунд после клика новая буква окажется в поле?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Двести с лишним миллисекунд — и всё это время поле пустует. Попробуйте теперь набрать что-нибудь в поле сами: каждая буква появляется рывком, потому что между нажатием и картинкой стоит целая отрисовка списка. Дело не в том, что React медленный. Дело в том, что для него оба изменения — буква в поле и новый список — одинаково срочные, и он делает их одной неразрывной работой.

Порог заметности примерно 100 мс: до него отклик воспринимается мгновенным, после — как задержка. Двести миллисекунд на букву — это уже «тормозит».

Разделить эти два изменения по важности внутри одного обработчика раньше было нечем. Отсюда дебаунс на каждом поле: подождать 300 мс тишины и только потом считать. Приём рабочий, но грубый — он не ускоряет отрисовку, а просто откладывает её целиком, и список отстаёт даже тогда, когда мог бы успеть.

Хук возвращает пару: признак «переход ещё идёт» и функцию, внутри которой обновления считаются несрочными. Переходом React называет само несрочное обновление, и с CSS-свойством transition у него общее только имя: переход в CSS, разобранный в прологе, дорисовывает кадры между двумя состояниями элемента.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Чтобы приём заработал, одного состояния мало. Нужны два: то, что видно в поле, — срочное, и то, по чему строится список, — несрочное. Обработчик обновляет оба, но по-разному.

Прогноз перед запуском: сколько коммитов напечатает пример и через сколько миллисекунд после клика буква окажется в поле?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Коммитов стало два — и это ожидаемо: React сначала отрисовал срочную часть, потом несрочную. Неожиданно другое: первый коммит всё равно пришёл через двести миллисекунд. Работы стало вдвое больше, а поле отвечает ровно так же медленно.

Причина в первом коммите. React перерисовал Search с новым text, а внутри снова встретил <Rows query={query} /> — новый элемент, обычный компонент, никаких причин его пропускать. И burn(200) отработал заново, хотя query не изменился.

Переход не делает отрисовку быстрее и не выносит её в другой поток. Он только даёт React право отложить и прервать эту работу — если есть что откладывать.

Срочная отрисовка обязана быть дешёвой — иначе откладывать нечего. Значит, тяжёлое поддерево должно уметь сказать «мои пропсы не менялись, пропустите меня». Ровно это делает memo из прошлой статьи главы: обёрнутый компонент сравнивает пропсы и при совпадении переиспользует прошлый результат.

Меняется одна строка — Rows заворачивается в React.memo. Прогноз: через сколько миллисекунд теперь придёт первый коммит?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый коммит приходит почти мгновенно: буква в поле, старый список на месте, isPending равен true. Второй — через двести миллисекунд, с новым списком. Между ними браузер успел нарисовать кадр, и именно это читатель воспринимает как «поле не тормозит».

Есть и вторая выгода, менее очевидная. Несрочные обновления, накопившиеся, пока React был занят, схлопываются: он рисует список сразу для последнего значения, промежуточные пропуская. Пример шлёт три «клавиши» одну за другой, не дожидаясь списка, — посчитайте, сколько раз отрисуется список.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три нажатия — одна отрисовка списка: сразу для «аббб». Оба промежуточных значения переход выбросил: к моменту, когда React освободился от срочных коммитов, они устарели. Уберите startTransition из обработчика и запустите снова — отрисовок станет четыре: начальная плюс по одной на нажатие, и каждая с двухсотмиллисекундной задержкой поля.

Дебаунс добивался похожего таймером, но вслепую: он ждёт свои триста миллисекунд независимо от того, успевает машина или нет. Переход считает по факту — на быстрой машине список догоняет каждую букву, на медленной сам пропускает промежуточные.

Компилятор из прошлой статьи расставляет мемоизацию сам, и React.memo писать руками не приходится: тяжёлое поддерево получает пропуск без вашего участия. Сам переход при этом никуда не девается — useTransition отвечает за приоритет, а не за пропуск повторной работы, и заменить его компилятор не может. Это два разных инструмента, которые нужны одновременно.

isPending — обычное булево состояние, которым React управляет сам: он ставит его в true перед несрочной отрисовкой и в false после коммита. Пользы от него ровно столько, сколько вы покажете на экране: приглушить устаревший список, показать полоску загрузки, заблокировать кнопку.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь ловушка, из-за которой переход чаще всего «не работает». Обработчик выглядит аккуратнее, если завернуть в startTransition обе строки сразу. Прогноз: через сколько миллисекунд буква окажется в поле?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый коммит приходит быстро, но в нём меняется только isPending: поле по-прежнему показывает старую букву. Новый text доезжает до экрана вместе со списком — через двести миллисекунд. Управляемое поле, обновляемое несрочно, — это поле, которое глотает символы: попробуйте набрать в нём длинную фразу.

Правило простое: в переход уходит только то, что читатель готов подождать. Значение поля ввода к такому не относится никогда.

Второе, что стоит знать про isPending, — где он живёт. Хук держит состояние в том компоненте, где вызван, и его переключение перерисовывает этот компонент целиком, дважды на каждый переход. Если useTransition вызван в корне приложения ради маленькой полоски внизу, вы платите двумя отрисовками всего дерева.

У useTransition есть условие: нужно добраться до места, где вызывается set. Если значение приходит пропсом сверху или из контекста, оборачивать нечего. Тогда откладывают не обновление, а само значение: useDeferredValue возвращает предыдущую версию, пока новая отрисовывается несрочно.

Прогноз: что напечатается в первом коммите — какое значение окажется в поле и какое в списке?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В первом коммите text уже новый, а deferred ещё старый — отсюда мгновенная буква в поле и приглушённый список рядом. Во втором значения совпадают, и приглушение снимается. Обратите внимание: состояние по-прежнему одно, второе значение React ведёт сам.

Условие то же самое, что и у перехода: Rows обязан уметь пропуститься. Уберите React.memo — и срочная отрисовка снова вызовет burn(200) со старым deferred, а вся выгода исчезнет. Оба хука экономят не работу, а её очерёдность.

useTransitionuseDeferredValue
Что откладываетсяобновления внутри вызоваодно конкретное значение
Нужен доступ к setданет — хватит самого значения
Сколько состоянийдва: срочное и несрочноеодно
Признак ожиданияisPendingсравнение value !== deferred
Типичное местопереключение вкладки, переход по маршруту, отправка формыфильтр и поиск по значению сверху

На первой отрисовке откладывать нечего — предыдущего значения ещё нет, и useDeferredValue вернёт текущее. Если тяжёлый список не должен появляться в самом первом кадре, ему дают начальное значение вторым аргументом: React отрисует сначала его, а настоящее подставит несрочно.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Главное заблуждение уже разобрано на примере: переход не ускоряет отрисовку. Он меняет очерёдность и разрешает выбросить незаконченную работу. Если тяжёлого поддерева, которое можно пропустить, в дереве нет, приём не даёт ничего, кроме лишних отрисовок.

Второе ограничение — гранулярность. React умеет остановиться между компонентами, но не внутри одного вызова функции. Компонент, который сам по себе считает пятьсот миллисекунд в цикле, остаётся неделимой пробкой: пометьте его несрочным — и вы получите ту же пробку, только позже. Такую работу режут на много маленьких компонентов или уносят из отрисовки совсем — об этом первая статья главы.

Третье: переходы не про сеть. Ожидание ответа сервера — это не приоритет отрисовки, а Suspense и загрузка данных. Впрочем, эти механизмы связаны: асинхронные функции внутри startTransition — основа действий, которыми в Next.js меняют данные на сервере. До них дойдёт глава про мутации.

Что происходитПриоритетПочему
Символ в поле вводасрочнопалец уже нажал клавишу, кадр нужен сейчас
Флажок, переключатель, кнопкасрочнореакция на нажатие — часть самого нажатия
Список результатов поисканесрочносекунда отставания незаметна, рывки заметны
Переключение вкладки с тяжёлым содержимымнесрочностарая вкладка остаётся видимой и осмысленной
Переход на другую страницунесрочнопрежний экран лучше пустого

И последнее: isPending нужен не всегда. Когда индикатор не рисуется, хук лишний — есть отдельная функция, не заводящая состояния и не вызывающая лишних отрисовок.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило отбора: пометьте несрочным то, устаревшую версию чего не стыдно показать ещё пару кадров.

  • React считает все обновления срочными: пока идёт отрисовка, браузер не рисует ничего — отсюда поле, отстающее от пальцев.
  • startTransition помечает обновление несрочным: React коммитит срочную часть первой, а несрочную может отложить и выбросить.
  • Переход не ускоряет отрисовку. Выигрыш появляется только тогда, когда срочной отрисовке есть что пропустить — то есть в паре с memo или React Compiler.
  • isPending живёт в том компоненте, где вызван хук, и стоит двух его отрисовок на каждый переход; без индикатора берите React.startTransition.
  • useDeferredValue откладывает значение, а не вызов set: годится, когда значение пришло сверху, а признаком ожидания служит value !== deferred.
  • Остановиться React умеет между компонентами, но не внутри одного вызова: полсекунды синхронного цикла остаются пробкой при любом приоритете.

1/4Обработчик поля вызывает setText(next) срочно и startTransition(() => setQuery(next)). Тяжёлый компонент списка не обёрнут в memo. Что изменится по сравнению с версией без перехода?

2/4Значение управляемого поля обновляется внутри startTransition вместе с фильтром. Что почувствует читатель?

3/4Компонент получает query пропсом сверху и делает const deferred = React.useDeferredValue(query). Что вернёт хук в отрисовке сразу после изменения пропса?

4/4Один компонент считает в цикле полсекунды и помечен несрочным через startTransition. Успеет ли React показать срочный кадр посреди этого счёта?

Расцепить поле и список

В коде одно состояние на двоих, и каждая буква стоит двухсот миллисекунд. Разделите обновления по срочности и дайте тяжёлому компоненту возможность пропуститься. Цель: первый коммит быстрее десяти миллисекунд.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Нужны две правки. Первая — второе состояние для списка, обновляемое несрочно. Вторая — React.memo на Rows: без неё срочная отрисовка всё равно вызовет burn.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первый коммит приходит за единицы миллисекунд со старым списком и isPending === true, второй — через двести, уже с новым.

То же самое одним состоянием

Перепишите решение прошлой задачи на useDeferredValue: второе состояние и startTransition должны исчезнуть, а приглушение устаревшего списка — остаться. Признак «устарело» возьмите из сравнения значений.

Ответьте заодно на вопрос: изменится ли что-нибудь, если React.memo с Rows снять?

Состояние остаётся одно, отложенную копию ведёт React.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Без React.memo выигрыш пропадает целиком. Срочная отрисовка передаст в Rows старое значение deferred, но элемент будет новый, и компонент отработает заново вместе со своим burn(200). Оба хука распоряжаются очерёдностью, а пропускать повторную работу умеет только мемоизация.

Индикатор, который ничего не стоит

В приложении есть корневой компонент с большим деревом внутри и кнопка перехода на другую страницу. Разработчик вызвал useTransition в корне, чтобы isPending был доступен всюду через контекст. Полоска загрузки рисуется в самом низу страницы. Что здесь не так и как переписать?

isPending — состояние того компонента, где вызван хук. Каждый переход переключает его дважды (в true и обратно), а значит дважды перерисовывает корень со всем деревом — ровно то, от чего переход должен был спасти.

Правильнее держать состояние ожидания там, где оно рисуется, а сам переход запускать без хука:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если полоска действительно нужна, useTransition вызывают в маленьком компоненте рядом с ней, а не в корне. Общее правило то же, что и для обычного состояния из главы про подъём: держать его настолько низко, насколько получается.