Как React не даёт несрочному рендеру потеряться: startTransition, useDeferredValue и Suspense

—

от автора

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

Это продолжение статьи о том, как React прерывает рендер, но читать её заранее не обязательно: всё нужное объясню по ходу.

Разбираем на одном примере — поиске по списку — и сверяемся с исходниками React 19.2.

function Search() {  const [inputQuery, setInputQuery] = useState("");  const [listQuery, setListQuery] = useState("");  const onChange = (e) => {    const { value } = e.target;    setInputQuery(value);    startTransition(() => setListQuery(value));  };  return <>    <input value={inputQuery} onChange={onChange} />    <List listQuery={listQuery} />  </>;}const List = memo(({ listQuery }) =>  filterProducts(listQuery).map((p) =>    <Row key={p.id} item={p} />));

Поле ввода обновляется срочно, а список — несрочно: его обновление обёрнуто в startTransition.


1. Срочное и несрочное

Сначала договоримся о словах — дальше без них никак.

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

Пример. Страница с поиском по каталогу: поле ввода и список товаров, до 10 000 штук. Найти товары быстро, дорого другое — отрендерить Row на каждый найденный, а их тысячи. Поэтому текст хранится дважды. inputQuery — для поля, его обновляем сразу. listQuery — для списка, его обновляем внутри startTransition: всё, что сделано внутри этой функции, React считает несрочным.

Что даёт несрочность. Срочный рендер React делает целиком, от начала до конца. Несрочный — кусками примерно по 5 мс, и между кусками отдаёт поток браузеру: тот успевает обработать нажатие клавиши и нарисовать кадр. Если за это время пришло срочное обновление, React выбрасывает недостроенный список, рендерит срочное, а несрочное потом начинает заново — уже с последним текстом.

Для пользователя это выглядит так. Символ появляется в поле сразу, а под ним пока остаётся старый список. Печатает быстро “123” — промежуточные списки для “1” и “12” так и не появятся, React сразу построит список для “123”.

А memo у List нужен, чтобы срочный рендер поля не тащил за собой список: без него List рендерился бы заново при каждом рендере Search, даже с тем же listQuery.

Приоритеты. Как React отличает срочное от несрочного? Сеттер из useState ничего не меняет сразу — он создаёт запрос на обновление (дальше для краткости — просто обновление). И этот запрос сразу получает приоритет — в коде React он называется lane. Приоритет зависит от того, откуда вызван сеттер. Нам понадобятся четыре группы приоритетов:

  • блокирующие — клик, ввод с клавиатуры, обычный setState вне startTransition. Рендерятся целиком, без пауз;

  • transition — обновления из startTransition. Рендерятся кусками, их можно прервать;

  • отложенные (deferred) — второй рендер useDeferredValue. Тоже несрочные, о них в разделе 3;

  • повторы — повторный рендер после того, как пришли данные, которых ждал Suspense. О них в разделе 4.

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

Технически приоритет — это бит в 31-битном числе (такие биты и называются lanes), поэтому один рендер может взять сразу несколько обновлений: все ожидающие transition React рендерит одной пачкой — кроме тех, в которых какой-то компонент во время рендера наткнулся на невыполненный промис — например, данные с сервера через use или код компонента через lazy. Такие transition React не трогает, пока промис не выполнится. Подробнее о том, как React прерывает рендер и почему приоритеты — это биты, — в первой статье.

Остаётся вопрос, с которого всё начинается: если несрочный рендер можно прерывать, что мешает прерывать его бесконечно?


2. Голодание: список так и не появляется

Смотрим на быстрый набор: пользователь печатает без остановки, и каждый новый символ приходит раньше, чем React успевает достроить список.

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

Если бы React на этом и остановился, список мог бы не обновиться вообще — пока пользователь не сделает паузу. Поэтому у каждого ожидающего обновления есть срок.

Срок. Как только React видит новое несрочное обновление, он записывает ему дедлайн: текущее время плюс 5 секунд. Дедлайн принадлежит самому обновлению, а не попытке его отрендерить: React хранит его в корне приложения, рядом с приоритетом обновления (технически это массив root.expirationTimes: индекс — номер бита приоритета, значение — дедлайн). Поэтому, когда React выбрасывает недостроенный список и начинает рендер заново, срок не сдвигается — он продолжает идти с момента первого нажатия. Сбрасывается он, только когда обновление наконец дошло до экрана.

Каждый раз, решая, что рендерить дальше, React сверяет сроки с текущим временем. Если срок прошёл, обновление считается просроченным.

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

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

Вот как это выглядит в DevTools → Performance: список строится около 300 мс, нажатие приходит каждые 150 мс. Через 5 секунд после начала набора короткие куски работы сменяются одной длинной задачей.

Быстрый набор в transition: 5 секунд коротких задач, затем одна длинная задача - просроченный рендер списка

Быстрый набор в transition: 5 секунд коротких задач, затем одна длинная задача — просроченный рендер списка

Выходит, transition не обещает, что ввод никогда не будет ждать. Он обещает, что ввод не будет ждать первые 5 секунд — а потом React всё-таки доведёт список до экрана, пусть и ценой одной заминки. Это осознанный выбор: устаревший на секунды список хуже, чем одна задержка ввода.

Сроки зависят от группы:

  • блокирующим срок не нужен: они и так рендерятся целиком;

  • transition и отложенные обновления useDeferredValue — 5 секунд;

  • повторы после Suspense — не истекают никогда.

И важное исключение — transition, который ждёт данных. Откуда берутся данные, React не знает. Он видит другое: во время рендера компонент прочитал промис, который ещё не выполнился, — например, запрос к серверу через use. Тогда срок этого transition стирается: пока промис не выполнен, торопить рендер бессмысленно, синхронный рендер упрётся в то же ожидание. Новый срок появится, только когда промис выполнится. Как React держит такой transition, разберём в разделе 4.


3. useDeferredValue: два рендера вместо одного

В нашем примере текст хранится дважды, и второе состояние обновляется в startTransition. Того же можно добиться одним состоянием:

function Search() {  const [query, setQuery] = useState("");  const deferredQuery = useDeferredValue(query);  return <>    <input value={query} onChange={(e) => setQuery(e.target.value)} />    <List listQuery={deferredQuery} />  </>;}

useDeferredValue возвращает “отстающую” копию значения: поле получает query сразу, а список — deferredQuery, который догоняет query несрочно.

Смотрим на нажатие “1”.

Первый рендер — срочный. Состояние тут одно — query, и setQuery("1") вызван из обработчика ввода, то есть это блокирующее обновление. Значит, React срочно рендерит Search целиком — вместе с вызовом useDeferredValue в нём. useDeferredValue видит: значение поменялось с "" на "1", а рендер срочный. Тогда хук возвращает старое значение "" и помечает компонент: нужен ещё один рендер, уже несрочный. Запроса на обновление при этом нет: новое значение у хука и так есть. Этот рендер получает приоритет из группы отложенных (deferred) — она отдельная от transition. List получает listQuery = "" — то же значение, что и раньше, — и благодаря memo не рендерится. Коммит: в поле “1”, под ним старый список.

Второй рендер — отложенный. Сразу после коммита React видит, что осталась работа с отложенным приоритетом, и запускает второй рендер Search. Теперь рендер несрочный, и useDeferredValue возвращает новое значение "1". List рендерится с ним — кусками, с паузами для браузера, как любой несрочный рендер.

Если пока строится список придёт “12”, всё как с transition. React выбрасывает недостроенный список и делает срочный рендер. useDeferredValue снова возвращает "" — на экране же до сих пор список для "", а список для “1” так и не был закоммичен. Потом отложенный рендер, уже с “12”. Промежуточные значения просто пропускаются.

Зачем тут memo. Без него List рендерился бы в каждом срочном рендере — просто потому, что перерендерился родитель. Со старым listQuery, но рендерился бы — а срочный рендер идёт целиком, без пауз. Получилось бы хуже, чем без хука: тяжёлый срочный рендер плюс ещё один отложенный. useDeferredValue экономит работу, только если компонент со старым значением может пропускать рендер.

Чем это не похоже на debounce. Debounce ждёт фиксированное время — скажем, 300 мс после последнего нажатия — и только потом обновляет список. useDeferredValue ничего не ждёт: отложенный рендер начинается сразу после срочного. На быстром устройстве список догоняет поле почти мгновенно, на медленном — отстаёт сильнее, но только на время самого рендера, без лишнего ожидания сверху. И отложенный рендер можно прервать: если список строится, а пользователь печатает дальше, символы в поле появляются сразу, не дожидаясь списка. Debounce так не умеет: когда время выходит, список обновляется обычным срочным рендером. Если пользователь как раз в этот момент снова начнёт печатать, символы появятся в поле, только когда список достроится.

И защита от голодания здесь та же, что у transition: у отложенного рендера тоже есть срок в 5 секунд. Если пользователь печатает без остановки, каждое нажатие прерывает отложенный рендер списка, и тот не успевает закончиться. Но через 5 секунд таких прерываний поле замрёт: React построит список целиком, без пауз, и нажатия клавиш будут ждать, пока он не закончит.


4. Transition и Suspense: старый список вместо спиннера

До сих пор все товары уже были в памяти браузера, и медленным был только рендер списка. Теперь пусть товары приходят с сервера.

function Search() {  const [inputQuery, setInputQuery] = useState("");  const [listQuery, setListQuery] = useState("");  const [isPending, startTransition] = useTransition();  const onChange = (e) => {    const { value } = e.target;    setInputQuery(value);    startTransition(() => setListQuery(value));  };  return <>    <input value={inputQuery} onChange={onChange} />    {isPending && <span>Обновляем…</span>}    <Suspense fallback={<Spinner />}>      <Results listQuery={listQuery} />    </Suspense>  </>;}function Results({ listQuery }) {  const products = use(fetchProducts(listQuery));  return products.map((p) => <Row key={p.id} item={p} />);}

use читает промис: если он ещё не выполнен, use прерывает компонент — бросает исключение, как throw, и до return дело не доходит. React ловит его и ждёт, пока промис выполнится; потом рендерит компонент заново. Это и называют приостановкой компонента. Пока React ждёт, вместо ветки можно показать fallback ближайшего <Suspense> — здесь спиннер. fetchProducts должна возвращать один и тот же промис для одного и того же запроса (например, класть их в Map): иначе каждый рендер создаёт новый запрос, и компонент так и не дождётся данных.

useTransition возвращает массив из двух элементов: флаг isPending — идёт ли сейчас transition — и функцию startTransition, которая работает так же, как обычная.

Смотрим на нажатие “1”, когда на экране уже есть список для "".

В обработчике два вызова сеттера: setInputQuery("1") — срочное обновление, setListQuery("1") — transition. Но startTransition из useTransition незаметно добавляет ещё одно срочное обновление: isPending = true. Поэтому первый коммит приходит сразу: в поле “1”, рядом надпись «Обновляем…», под ними старый список.

Затем React несрочно рендерит transition, и Results получает listQuery = "1". Он вызывает use(fetchProducts("1")), промис ещё не выполнен — компонент приостанавливается. Теперь React решает, что делать с <Suspense> вокруг Results. Он проверяет две вещи: в этом рендере только обновления с приоритетом transition, а этот <Suspense> уже показывает содержимое — список для "". В такой ситуации React не коммитит рендер вообще, и на экране остаётся старый список. Для пользователя так лучше: старый список полезнее, чем спиннер на его месте.

Пока промис не выполнен, transition помечен как приостановленный, а его пятисекундный срок, который защищает от голодания, стёрт. Ждать так React готов сколько угодно.

Когда данные приходят, React снова рендерит transition — теперь use сразу отдаёт товары. В этом же рендере isPending становится false: это обновление идёт вместе с transition. Поэтому новый список и исчезновение «Обновляем…» попадают на экран одним коммитом — индикатор пропадает ровно тогда, когда появляется результат.

Для сравнения — без transition. Допустим, в обработчике стоит просто setListQuery(value), без startTransition. Тогда рендер блокирующий: Results приостанавливается, и React показывает fallback. Список пропадает, на его месте спиннер, и при каждом новом символе снова спиннер. Интерфейс мигает.

Когда спиннер покажется и в transition. Старый экран React держит, только если пользователь уже видит содержимое <Suspense>. Если <Suspense> появляется на экране впервые в этом же рендере, прятать нечего, и React покажет его fallback даже внутри transition.

То же с useDeferredValue. Отложенный рендер тоже несрочный, поэтому правило то же: пока данные для нового запроса грузятся, на экране остаются старые результаты.

const deferredQuery = useDeferredValue(query);const isStale = query !== deferredQuery;<Suspense fallback={<Spinner />}>  <div style={{ opacity: isStale ? 0.5 : 1 }}>    <Results listQuery={deferredQuery} />  </div></Suspense>

isPending тут нет, но он и не нужен: пока query и deferredQuery различаются, результаты устарели — можно их приглушить.


Итог

  • Что если несрочный рендер прерывать бесконечно? У каждого несрочного обновления есть срок — 5 секунд с момента, когда оно появилось. React хранит этот срок в корне приложения, рядом с приоритетом обновления, поэтому перезапуски рендера срок не сдвигают. Когда срок прошёл, React рендерит обновление целиком, без пауз, и ввод на это время ждёт.

  • Как работает useDeferredValue? Два рендера. В срочном рендере хук возвращает старое значение и заказывает второй рендер, отложенный. В нём хук возвращает новое значение, и этот рендер можно прервать.

  • Что видит пользователь, пока грузятся данные? Если у рендера приоритет transition или deferred, а <Suspense> уже показывает содержимое, — старый интерфейс, сколько бы ни шла загрузка. Спиннер появится, только если <Suspense> появляется на экране впервые или обновление срочное.

Что это значит для вашего кода:

  • Не рассчитывайте, что несрочный рендер (startTransition или useDeferredValue) никогда не заблокирует ввод. Если пользователь долго печатает без пауз, через 5 секунд будет одна заминка на целый рендер. Тяжёлый рендер всё равно стоит облегчать.

  • useDeferredValue работает только вместе с memo у компонента, который получает отложенное значение. Без memo он делает хуже.

  • Обновления, которые грузят данные, делайте несрочными — через startTransition или useDeferredValue: тогда старый интерфейс остаётся на экране вместо спиннера. А показать, что идёт загрузка, можно через isPending (для startTransition) или сравнение значения с отложенным (для useDeferredValue).

Ещё одна проблема прерываемого рендера здесь осталась за кадром — tearing. Если компонент читает не состояние React, а внешнюю переменную, которая меняется прямо во время рендера, разные куски одного рендера увидят разные значения. Как от этого защищает useSyncExternalStore, разобрано в статье про подписку на поля контекста.

А как React прерывает рендер и при этом не теряет обновления, которые пропустил, — разобрано в первой статье.

Если что-то осталось непонятным — пишите в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1104382/