Как React не даёт несрочному рендеру потеряться: startTransition, useDeferredValue и Suspense
- суббота, 7 ноября 2026 г. в 00:00:09
Представьте поиск по каталогу: поле ввода, а под ним список из тысяч товаров. Перестраивать такой список долго, и если делать это на каждое нажатие, поле начнёт тормозить. Поэтому обновление списка можно пометить как несрочное: тогда 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.
Сначала договоримся о словах - дальше без них никак.
Рендер и коммит. Когда состояние меняется, 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. Тоже несрочные;
повторы - повторный рендер после того, как пришли данные, которых ждал Suspense.
Ожидающие обновления React рендерит не все разом, а проходами, и в каждый проход берёт одну группу приоритетов. После нажатия клавиши сначала идёт проход со срочными обновлениями, потом отдельный - с transition.
Технически приоритет - это бит в 31-битном числе (такие биты и называются lanes), поэтому один рендер может взять сразу несколько обновлений: все ожидающие transition React рендерит одной пачкой, все отложенные - другой. Кроме тех, в которых какой-то компонент во время рендера наткнулся на невыполненный промис - например, данные с сервера через use или код компонента через lazy. Такие обновления React не трогает, пока промис не выполнится. Подробнее о том, как React прерывает рендер и почему приоритеты - это биты, - в первой статье.
Остаётся вопрос, с которого всё начинается: если несрочный рендер можно прерывать, что мешает прерывать его бесконечно?
Смотрим на быстрый набор: пользователь печатает без остановки, и каждый новый символ приходит раньше, чем 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 секунд - а потом React всё-таки доведёт список до экрана, пусть и ценой одной заминки. Это осознанный выбор: устаревший на секунды список хуже, чем одна задержка ввода.
Сроки зависят от группы:
блокирующим срок не нужен: они и так рендерятся целиком;
transition и отложенные обновления useDeferredValue - 5 секунд;
повторы после Suspense - не истекают никогда.
И важное исключение - несрочное обновление (transition или отложенное), которое ждёт данных. Откуда берутся данные, React не знает. Он видит другое: во время рендера компонент прочитал промис, который ещё не выполнился, - например, запрос к серверу через use. Тогда срок этого обновления стирается: пока промис не выполнен, торопить рендер бессмысленно, синхронный рендер упрётся в то же ожидание. Новый срок появится, только когда промис выполнится.
В нашем примере текст хранится в двух состояниях, и второе обновляется в 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. Запроса на обновление при этом нет: новое значение у хука и так есть, а по приоритету на компоненте React узнаёт, что его нужно отрендерить ещё раз, уже несрочно. 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 построит список целиком, без пауз, и нажатия клавиш будут ждать, пока он не закончит.
До сих пор все товары уже были в памяти браузера, и медленным был только рендер списка. Теперь пусть товары приходят с сервера.
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 секунд будет одна заминка на целый рендер. Тяжёлый рендер всё равно стоит облегчать - например, виртуализацией (react-window, TanStack Virtual).
useDeferredValue работает только вместе с memo у компонента, который получает отложенное значение. Без memo он делает хуже.
Обновления, которые грузят данные, делайте несрочными - через startTransition или useDeferredValue: тогда старый интерфейс остаётся на экране вместо спиннера. А показать, что идёт загрузка, можно через isPending (для startTransition) или сравнение значения с отложенным (для useDeferredValue).
Ещё одна проблема прерываемого рендера здесь осталась за кадром - tearing. Если компонент читает не состояние React, а внешнюю переменную, которая меняется прямо во время рендера, разные куски одного рендера увидят разные значения. Как от этого защищает useSyncExternalStore, разобрано в статье про подписку на поля контекста.
А как React прерывает рендер и при этом не теряет обновления, которые пропустил, - разобрано в первой статье.
Если что-то осталось непонятным - пишите в комментариях.