Срочное и несрочное: useTransition и useDeferredValue
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Поле ввода, под которым висит длинный отфильтрованный список, — классическое место, где React перестаёт поспевать за пальцами: каждая буква перерисовывает весь список, и браузер не успевает показать саму букву. Чинится это не ускорением списка, а фразой «это подождёт»: обновление помечается несрочным, и React разрешает себе отложить его ради того, что читатель видит прямо сейчас.
Рукописный render() из «Рендера из состояния и делегирования» тормозил по причине, о которой там речи не было: пока он пересобирал разметку, страница стояла — браузеру просто нечем было заняться, поток был занят вашим кодом. React ничего в этом не изменил: его отрисовка — такой же синхронный код в том же единственном потоке.
Ниже кнопка добавляет букву в запрос, а список «стоит» двести миллисекунд — вместо честной вёрстки трёхсот строк поток просто занят. Прежде чем нажимать Run, скажите себе: через сколько миллисекунд после клика новая буква окажется в поле?
Двести с лишним миллисекунд — и всё это время поле пустует. Попробуйте теперь набрать что-нибудь в поле сами: каждая буква появляется рывком, потому что между нажатием и картинкой стоит целая отрисовка списка. Дело не в том, что React медленный. Дело в том, что для него оба изменения — буква в поле и новый список — одинаково срочные, и он делает их одной неразрывной работой.
Порог заметности примерно 100 мс: до него отклик воспринимается мгновенным, после — как задержка. Двести миллисекунд на букву — это уже «тормозит».
Разделить эти два изменения по важности внутри одного обработчика раньше было нечем. Отсюда дебаунс на каждом поле: подождать 300 мс тишины и только потом считать. Приём рабочий, но грубый — он не ускоряет отрисовку, а просто откладывает её целиком, и список отстаёт даже тогда, когда мог бы успеть.
Хук возвращает пару: признак «переход ещё идёт» и функцию, внутри которой обновления считаются несрочными. Переходом React называет само несрочное обновление, и с CSS-свойством transition у него общее только имя: переход в CSS, разобранный в прологе, дорисовывает кадры между двумя состояниями элемента.
Чтобы приём заработал, одного состояния мало. Нужны два: то, что видно в поле, — срочное, и то, по чему строится список, — несрочное. Обработчик обновляет оба, но по-разному.
Прогноз перед запуском: сколько коммитов напечатает пример и через сколько миллисекунд после клика буква окажется в поле?
Коммитов стало два — и это ожидаемо: React сначала отрисовал срочную часть, потом несрочную. Неожиданно другое: первый коммит всё равно пришёл через двести миллисекунд. Работы стало вдвое больше, а поле отвечает ровно так же медленно.
Причина в первом коммите. React перерисовал Search с новым text, а внутри снова встретил <Rows query={query} /> — новый элемент, обычный компонент, никаких причин его пропускать. И burn(200) отработал заново, хотя query не изменился.
Переход не делает отрисовку быстрее и не выносит её в другой поток. Он только даёт React право отложить и прервать эту работу — если есть что откладывать.
Срочная отрисовка обязана быть дешёвой — иначе откладывать нечего. Значит, тяжёлое поддерево должно уметь сказать «мои пропсы не менялись, пропустите меня». Ровно это делает memo из прошлой статьи главы: обёрнутый компонент сравнивает пропсы и при совпадении переиспользует прошлый результат.
Меняется одна строка — Rows заворачивается в React.memo. Прогноз: через сколько миллисекунд теперь придёт первый коммит?
Первый коммит приходит почти мгновенно: буква в поле, старый список на месте, isPending равен true. Второй — через двести миллисекунд, с новым списком. Между ними браузер успел нарисовать кадр, и именно это читатель воспринимает как «поле не тормозит».
Есть и вторая выгода, менее очевидная. Несрочные обновления, накопившиеся, пока React был занят, схлопываются: он рисует список сразу для последнего значения, промежуточные пропуская. Пример шлёт три «клавиши» одну за другой, не дожидаясь списка, — посчитайте, сколько раз отрисуется список.
Три нажатия — одна отрисовка списка: сразу для «аббб». Оба промежуточных значения переход выбросил: к моменту, когда React освободился от срочных коммитов, они устарели. Уберите startTransition из обработчика и запустите снова — отрисовок станет четыре: начальная плюс по одной на нажатие, и каждая с двухсотмиллисекундной задержкой поля.
Дебаунс добивался похожего таймером, но вслепую: он ждёт свои триста миллисекунд независимо от того, успевает машина или нет. Переход считает по факту — на быстрой машине список догоняет каждую букву, на медленной сам пропускает промежуточные.
Компилятор из прошлой статьи расставляет мемоизацию сам, и React.memo писать руками не приходится: тяжёлое поддерево получает пропуск без вашего участия. Сам переход при этом никуда не девается — useTransition отвечает за приоритет, а не за пропуск повторной работы, и заменить его компилятор не может. Это два разных инструмента, которые нужны одновременно.
isPending — обычное булево состояние, которым React управляет сам: он ставит его в true перед несрочной отрисовкой и в false после коммита. Пользы от него ровно столько, сколько вы покажете на экране: приглушить устаревший список, показать полоску загрузки, заблокировать кнопку.
Теперь ловушка, из-за которой переход чаще всего «не работает». Обработчик выглядит аккуратнее, если завернуть в startTransition обе строки сразу. Прогноз: через сколько миллисекунд буква окажется в поле?
Первый коммит приходит быстро, но в нём меняется только isPending: поле по-прежнему показывает старую букву. Новый text доезжает до экрана вместе со списком — через двести миллисекунд. Управляемое поле, обновляемое несрочно, — это поле, которое глотает символы: попробуйте набрать в нём длинную фразу.
Правило простое: в переход уходит только то, что читатель готов подождать. Значение поля ввода к такому не относится никогда.
Второе, что стоит знать про isPending, — где он живёт. Хук держит состояние в том компоненте, где вызван, и его переключение перерисовывает этот компонент целиком, дважды на каждый переход. Если useTransition вызван в корне приложения ради маленькой полоски внизу, вы платите двумя отрисовками всего дерева.
У useTransition есть условие: нужно добраться до места, где вызывается set. Если значение приходит пропсом сверху или из контекста, оборачивать нечего. Тогда откладывают не обновление, а само значение: useDeferredValue возвращает предыдущую версию, пока новая отрисовывается несрочно.
Прогноз: что напечатается в первом коммите — какое значение окажется в поле и какое в списке?
В первом коммите text уже новый, а deferred ещё старый — отсюда мгновенная буква в поле и приглушённый список рядом. Во втором значения совпадают, и приглушение снимается. Обратите внимание: состояние по-прежнему одно, второе значение React ведёт сам.
Условие то же самое, что и у перехода: Rows обязан уметь пропуститься. Уберите React.memo — и срочная отрисовка снова вызовет burn(200) со старым deferred, а вся выгода исчезнет. Оба хука экономят не работу, а её очерёдность.
useTransition | useDeferredValue | |
|---|---|---|
| Что откладывается | обновления внутри вызова | одно конкретное значение |
Нужен доступ к set | да | нет — хватит самого значения |
| Сколько состояний | два: срочное и несрочное | одно |
| Признак ожидания | isPending | сравнение value !== deferred |
| Типичное место | переключение вкладки, переход по маршруту, отправка формы | фильтр и поиск по значению сверху |
На первой отрисовке откладывать нечего — предыдущего значения ещё нет, и useDeferredValue вернёт текущее. Если тяжёлый список не должен появляться в самом первом кадре, ему дают начальное значение вторым аргументом: React отрисует сначала его, а настоящее подставит несрочно.
Главное заблуждение уже разобрано на примере: переход не ускоряет отрисовку. Он меняет очерёдность и разрешает выбросить незаконченную работу. Если тяжёлого поддерева, которое можно пропустить, в дереве нет, приём не даёт ничего, кроме лишних отрисовок.
Второе ограничение — гранулярность. React умеет остановиться между компонентами, но не внутри одного вызова функции. Компонент, который сам по себе считает пятьсот миллисекунд в цикле, остаётся неделимой пробкой: пометьте его несрочным — и вы получите ту же пробку, только позже. Такую работу режут на много маленьких компонентов или уносят из отрисовки совсем — об этом первая статья главы.
Третье: переходы не про сеть. Ожидание ответа сервера — это не приоритет отрисовки, а Suspense и загрузка данных. Впрочем, эти механизмы связаны: асинхронные функции внутри startTransition — основа действий, которыми в Next.js меняют данные на сервере. До них дойдёт глава про мутации.
| Что происходит | Приоритет | Почему |
|---|---|---|
| Символ в поле ввода | срочно | палец уже нажал клавишу, кадр нужен сейчас |
| Флажок, переключатель, кнопка | срочно | реакция на нажатие — часть самого нажатия |
| Список результатов поиска | несрочно | секунда отставания незаметна, рывки заметны |
| Переключение вкладки с тяжёлым содержимым | несрочно | старая вкладка остаётся видимой и осмысленной |
| Переход на другую страницу | несрочно | прежний экран лучше пустого |
И последнее: isPending нужен не всегда. Когда индикатор не рисуется, хук лишний — есть отдельная функция, не заводящая состояния и не вызывающая лишних отрисовок.
Правило отбора: пометьте несрочным то, устаревшую версию чего не стыдно показать ещё пару кадров.
- 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 показать срочный кадр посреди этого счёта?
Расцепить поле и список
В коде одно состояние на двоих, и каждая буква стоит двухсот миллисекунд. Разделите обновления по срочности и дайте тяжёлому компоненту возможность пропуститься. Цель: первый коммит быстрее десяти миллисекунд.
Нужны две правки. Первая — второе состояние для списка, обновляемое несрочно. Вторая — React.memo на Rows: без неё срочная отрисовка всё равно вызовет burn.
Первый коммит приходит за единицы миллисекунд со старым списком и isPending === true, второй — через двести, уже с новым.
То же самое одним состоянием
Перепишите решение прошлой задачи на useDeferredValue: второе состояние и startTransition должны исчезнуть, а приглушение устаревшего списка — остаться. Признак «устарело» возьмите из сравнения значений.
Ответьте заодно на вопрос: изменится ли что-нибудь, если React.memo с Rows снять?
Состояние остаётся одно, отложенную копию ведёт React.
Без React.memo выигрыш пропадает целиком. Срочная отрисовка передаст в Rows старое значение deferred, но элемент будет новый, и компонент отработает заново вместе со своим burn(200). Оба хука распоряжаются очерёдностью, а пропускать повторную работу умеет только мемоизация.
Индикатор, который ничего не стоит
В приложении есть корневой компонент с большим деревом внутри и кнопка перехода на другую страницу. Разработчик вызвал useTransition в корне, чтобы isPending был доступен всюду через контекст. Полоска загрузки рисуется в самом низу страницы. Что здесь не так и как переписать?
isPending — состояние того компонента, где вызван хук. Каждый переход переключает его дважды (в true и обратно), а значит дважды перерисовывает корень со всем деревом — ровно то, от чего переход должен был спасти.
Правильнее держать состояние ожидания там, где оно рисуется, а сам переход запускать без хука:
Если полоска действительно нужна, useTransition вызывают в маленьком компоненте рядом с ней, а не в корне. Общее правило то же, что и для обычного состояния из главы про подъём: держать его настолько низко, насколько получается.