InquirerSara Duterte to use personal funds for HK trip on Nov. 6-8Bollywood HungamaBREAKING: Maatrubhumi release date CONFIRMED; Salman Khan-starrer to release on December 4; to clash with EethaThe Jerusalem PostTrump administration coordinated with Iran on deportations for asylum seekers in middle of war – APDaily MaverickLETTER TO MAHLAMBA NDLOPFU: Sies for Sisisi’s creative accounting and the cat with crocodile tearsInquirer EntertainmentDavao Conyo leads TikTok Awards PH 2026 winners heading to SEA tiltZDF heuteAktuelle Pressemitteilungen des ZDFNHK 社会中山間地域の訪問介護 移動コストを報酬加算で評価案 厚労省Wirtualna PolskaUprowadzili i więzili mieszkańca Gdyni. To odkryli w aucie porywaczyNMEGrimes making film debut in entirely AI-generated ‘Gods Don’t Give Gifts’عالم التقنيةHonor WIN 2 يتفوق في الأداء ويتصدر تصنيفات أكتوبر متقدماً على OnePlus 16ColliderEvery Lord of the Rings Book, Ranked by GoodreadsDW 中文澳门国安首案:民主派前议员区锦新被重判12年半
The Daily Newsstand · Free, Always
Friday, November 6, 2026

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

Translate

Представьте поиск по каталогу: поле ввода, а под ним список из тысяч товаров. Перестраивать такой список долго, и если делать это на каждое нажатие, поле начнёт тормозить. Поэтому обновление списка можно пометить как несрочное: тогда 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 прерывает рендер и при этом не теряет обновления, которые пропустил, - разобрано в первой статье.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.