javascript

Как React прерывает рендер: конкурентный рендеринг изнутри

  • пятница, 30 октября 2026 г. в 00:00:15
https://habr.com/ru/articles/1101070/

Начиная с React 18 рендер можно прервать: срочные обновления обгоняют несрочные, даже если React уже взялся за несрочное. Если задуматься, вопросов много:

  • Как React посреди рендера отдаёт поток браузеру, чтобы тот обработал ввод, а потом продолжает с того же места?

  • Почему до React 16 он так не умел?

  • Кто решает, какие обновления срочные, и почему большинство рендеров не прерывается вообще?

  • Что происходит с незакоммиченными обновлениями, когда пришло что-то срочное?

  • И главное, как обновления, которые выстраиваются в очередь, не путаются в порядке, если срочное пришло позже, но должно обогнать несрочные?

Команда React сознательно не нагружает этим разработчиков: в анонсе React 18 конкурентность названа деталью реализации — в общих чертах понимать её стоит, а знать, как она устроена, необязательно. И это разумно: пользоваться startTransition можно, не заглядывая под капот. Но мне стало интересно, и я полез в исходники React 19.

Всё разбираем на одном простом примере — поиске по списку.

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 два дерева: current - соответствует тому, что сейчас на экране, и WIP-дерево (work-in-progress) — его React строит, когда что-то меняется.

React не правит current на месте. Он строит рядом WIP-дерево: вызывает компоненты, сравнивает их новый результат с тем, что лежит в current, и отмечает, что поменялось. Это и есть рендер. В React это слово не про рисование: работа идёт в памяти, DOM и экран не меняются.

Когда WIP-дерево готово, React применяет найденные изменения к DOM, и WIP-дерево становится новым current. Это коммит. После него браузер перерисовывает страницу.

Узлы двух деревьев связаны попарно ссылкой alternate: обходя WIP-дерево, React по этой ссылке находит парный узел в current и берёт оттуда прошлое состояние хуков и прошлый результат рендера. Узлы WIP-дерева обычно даже не создаются с нуля. После коммита деревья меняются ролями: WIP-дерево становится current, а прежнее current идёт в дело при следующем рендере: из его узлов React и строит новое WIP-дерево, перезаписывая их новыми данными.

Что является причиной рендера? Отдельный компонент чаще всего перерендеривается по одной из трёх причин:

  • изменилось его состояние;

  • перерендерился родитель;

  • изменился контекст.

А как же пропсы? Их изменение — не отдельная причина: новые пропсы появляются, только когда перерендерился родитель.

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

  • самый первый рендер;

  • внешний стор в useSyncExternalStore;

  • данные, которых компонент ждал в Suspense;

  • forceUpdate у классового компонента;

  • в режиме разработки StrictMode, который вызывает компоненты дважды, и Fast Refresh.

Их оставим в стороне — дальше достаточно проследить путь одного обновления состояния.

Путь такой. Срабатывает сеттер из useState или dispatch из useReducer - разницы нет: внутри React useState сделан через useReducer, поэтому сеттер — это тот же dispatch, и путь у обновления один и тот же.

  1. Запрос. Сеттер ничего не меняет на экране сразу — он только создаёт запрос на обновление (дальше для краткости — просто обновление). Обновление встаёт в очередь обновлений своего хука. У каждого функционального компонента есть список хуков (связный список; у классов устроено иначе), а у каждого хука — своя такая очередь: тоже связный список, но кольцевой и с указателем на хвост. Ещё обновление получает приоритет — он зависит от того, откуда вызван сеттер: из обработчика клика, из startTransition, из таймера. В коде React приоритет называется lane. Но в очереди приоритет роли не играет: новое обновление всегда встаёт в хвост. Очередь — общий объект для пары узлов в current и WIP-дереве, поэтому обновление не потеряется, даже если WIP-дерево выбросят (такое бывает — об этом ниже).

  2. Планирование. Приоритет есть не только у обновления, но и у каждого узла дерева. У узла два таких поля: lanes - приоритеты обновлений, которые ждут в самом компоненте, и childLanes - приоритеты обновлений, которые ждут в его дочерних компонентах на любой глубине. React записывает приоритет обновления в lanes компонента, а потом поднимается по родительским компонентам до корня и добавляет этот приоритет в childLanes каждого. Вызывать компоненты — то есть рендерить — React при этом сразу не начинает. React ждёт, пока закончится текущий код — например, весь обработчик клика, — чтобы все обновления из него попали в один рендер. И только потом решает, что делать: самое срочное (клик, ввод с клавиатуры) рендерит тут же, а остальное отдаёт планировщику — отдельному пакету React, который решает, когда эту работу выполнить.

  3. Рендер. За один рендер React берёт не все ожидающие обновления, а только самые срочные из них — остальные подождут следующего рендера. React обходит дерево от корня. По childLanes он видит, в каких ветках ждут обновления с этим приоритетом, и спускается только туда, а остальные пропускает. По lanes находит сами компоненты с такими обновлениями. Их и их дочерние компоненты React вызывает заново. Новый результат сравнивается с тем, что лежит в current, и собирается в WIP-дерево. Экран всё это время не меняется.

  4. Коммит. Изменения из готового WIP-дерева применяются к DOM за один раз, без пауз, и WIP-дерево становится current. Поэтому пользователь никогда не видит интерфейс наполовину обновлённым. Дальше браузер рисует страницу.

Теперь, что такое конкурентный рендеринг. Без него React обрабатывает обновления по очереди: взял пачку (несколько обновлений подряд он объединяет в одну), довёл до экрана, взял следующую. Пока идёт тяжёлое обновление, всё остальное ждёт, и интерфейс “замерзает”. Тяжёлое обновление блокирует поток — а в нём же браузер обрабатывает ввод и рисует страницу. Поэтому, пока обновление не закончится, ни нажатие клавиши, ни перерисовка не произойдут.

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


2. Пример: поиск по списку

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

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

Это тот же код, что во вступлении, — повторю его здесь, чтобы не бегать туда-сюда.

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} />
));

Оба состояния — просто строки, записать строку ничего не стоит. И найти товары тоже быстро: filterProducts в List - просто поиск текста в названиях товаров, он укладывается в пару миллисекунд. Дорого другое — отрендерить Row на каждый найденный товар, а их тысячи.

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

startTransition - функция из React: обновления, сделанные внутри неё, становятся несрочными. Вот что она меняет для пользователя.

Без startTransition. Пользователь нажал клавишу — и страница перестаёт отвечать, пока не перестроится весь список. Только потом символ появляется в поле. Если быстро напечатать “123”, React построит три списка подряд: для “1”, “12” и “123”.

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

Отсюда и два состояния с одним и тем же текстом. Без startTransition хватило бы одного. А так у них разный приоритет: inputQuery обновляется сразу, listQuery - когда получится.

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

Дальше по ходу статьи следим за четырьмя вещами:

  • inputQuery - текст в поле ввода;

  • listQuery - текст, по которому строится список;

  • на экране — то, что сейчас в DOM;

  • WIP-дерево — то, что React считает в памяти, но ещё не показал.


3. Как React останавливает рендер

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

  • inputQuery: "1";

  • listQuery: "", а "1" ждёт несрочного рендера;

  • на экране: в поле “1”, под ним все товары, без фильтра;

  • WIP-дерево: в поле “1”, под ним товары с “1” в названии — готова половина

JavaScript не умеет приостанавливать функцию: пока она выполняется, браузер не может ни обработать клик, ни нарисовать кадр. Единственный способ отдать управление — закончить функцию, то есть return. Ровно это React и делает.

WIP-дерево React строит по одному компоненту. После каждого он спрашивает планировщика — отдельный пакет React, который решает, когда работать, — “не пора ли уступить?”. На одну порцию работы планировщик даёт 5 мс — это просто константа в его коде (frameYieldMs = 5). При 60 кадрах в секунду кадр длится около 16 мс, так что браузеру остаётся время обработать ввод и перерисовать страницу. На 120-герцовом мониторе кадр — около 8 мс, а порция всё те же 5 мс: частоту экрана планировщик не учитывает.

Компоненты React обрабатывает в цикле: взял компонент, обработал, спросил планировщика, взял следующий. Если планировщик ответил “пора уступить”, React просто не берёт следующий компонент — цикл заканчивается, и функция, которая рендерит всё дерево, выходит через return, хотя React прошёл только часть дерева.

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

А откуда React знает, с какого компонента продолжить, если стек исчез? Позиция в дереве хранится в обычной переменной workInProgress снаружи — она переживает паузу.

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

Вывод для практики. React уступает браузеру только между компонентами. Поэтому важно, в чём именно тяжесть.

Если дочерних компонентов просто много — как тысячи Row в нашем List, - работа уже нарезана: каждый Row отдельный компонент, и React может уступить браузеру между любыми двумя. Конечно можно еще виртуализировать.

А если один компонент сам по себе считает 200 мс — например, сортирует огромный массив прямо в теле, — 5-миллисекундные порции не помогут: этот компонент рендерится целиком. startTransition тоже не спасёт: работа станет несрочной, но когда до неё дойдёт очередь, все 200 мс пройдут одним куском. Такой расчёт надо убирать из рендера:

  • Web Worker — отдельный поток в браузере: код в нём выполняется параллельно и не мешает ни React, ни обработке ввода. Считаться быстрее он не станет, но интерфейс не замрёт.

  • Нарезать расчёт самому — считать порциями и между ними отдавать управление браузеру, например через setTimeout. По сути, повторить вручную то, что React делает с компонентами.


4. Почему до React 16 так было нельзя

Смотрим на тот же момент, когда React строит дерево в памяти. Почему его вообще можно бросить на полпути?

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

В React 16 обход переписали. У каждого компонента появился объект — fiber, с тремя ссылками: на первый дочерний компонент (child), на следующий соседний (sibling) и на родительский (return). Дерево стало связным списком, по которому идут обычным циклом: есть дочерний — вниз, нет — к соседнему, нет соседнего — вверх к родительскому и дальше к его соседнему. Чтобы помнить позицию, хватает одной переменной — той самой workInProgress.

Вывод для практики. Представьте: React успел вызвать половину Row для “1”, и тут пользователь напечатал “2”. Недоделанное WIP-дерево React выбрасывает и строит заново — уже для “12”. Получается, многие Row вызваны дважды, а результат первого вызова на экран так и не попал. Поэтому в теле компонента не должно быть действий, которые влияют на что-то снаружи. Например, если отправлять запрос прямо в теле компонента, запросов уйдёт столько, сколько раз React его вызвал, — в том числе для выброшенных рендеров. То же с подписками и с изменением внешних переменных.

Такому коду место в эффектах или в обработчиках событий. Эффекты запускаются только после коммита — то есть только для того, что React действительно применил к DOM. А обработчики срабатывают на действие пользователя, а не на рендер.


5. Кто решает, что срочно — и что вообще прерывается

Смотрим на момент между вызовом сеттера и рендером: компоненты React ещё не вызывает, а решает, когда это делать.

Каждое обновление получает приоритет по тому, откуда пришло. В нашем onChange:

  • setInputQuery вызван прямо в обработчике ввода — срочный;

  • setListQuery вызван там же, но внутри startTransition - несрочный.

Главное, что легко упустить: по 5 мс React режет только несрочные рендеры.

  • Целиком, без пауз: клик, ввод с клавиатуры, скролл, движение мыши, обычный setState после fetch или по таймеру.

  • Порциями по ~5 мс, с паузами: startTransition, useDeferredValue, повторы Suspense.

Приоритеты внутри первой группы разные: клик срочнее скролла, а скролл срочнее setState после fetch. От этого зависит, как скоро рендер начнётся. Но начавшись, он идёт без пауз.

Кто сравнивает приоритеты? setState не рендерит сразу. Он ставит микротаску через queueMicrotask - в обычную очередь микротасок. Она выполнится, как только закончится текущий код, раньше любых макротасок и отрисовки. Микротаска одна на все setState подряд: оба вызова в нашем onChange разберёт одна и та же микротаска.

В микротаске React берёт самое срочное из ожидающего:

  • клик или ввод (наш setInputQuery) - рендерит прямо здесь же, в микротаске, без планировщика;

  • всё остальное (в том числе наш setListQuery) - отдаёт планировщику как задачу с приоритетом;

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

Для одного корня — того, что создаёт createRoot, обычно это всё приложение, — React держит у планировщика только одну задачу. Когда приходит более срочное обновление — например, скролл посреди недостроенного списка, — React в микротаске сам отменяет старую задачу и ставит новую, с другим приоритетом.

Вывод для практики. Обычный setState с большим ответом сервера рендерится одним куском и может подвесить ввод. Если рендер после загрузки тяжёлый — оберните обновление в startTransition: работы меньше не станет, но её можно будет прервать. Ну, и, опять же, можно уменьшить саму работу: виртуализировать список или загружать данные порциями.


6. Что происходит с недоделанным WIP-деревом

Смотрим на момент, когда список для “1” посчитан в памяти наполовину — и тут пользователь печатает “2”.

  • inputQuery: "1" → "12";

  • listQuery: "", а "1" и "12" ждут несрочного рендера;

  • на экране: в поле “1”, под ним все товары, без фильтра;

  • WIP-дерево: в поле “1”, под ним товары с “1” в названии — готова половина

Срочный setInputQuery("12") рендерится в микротаске. Но WIP-дерево посчитано для несрочного приоритета, и в нём уже применён listQuery = "1". Доделать его — значит заставить ввод ждать список. Вырезать из него listQuery - нельзя, он уже разошёлся по половине дерева.

Поэтому React сравнивает: WIP-дерево посчитано не для того приоритета, что нужен сейчас? Тогда оно не нужно — выбрасываем. Новое WIP-дерево строится с нуля — от current, дерева, которое соответствует тому, что на экране. Срочный рендер идёт без пауз до конца, коммит — и поле показывает “12”.

Сразу после этого React запускает несрочную работу заново, тоже с нуля. И тут бонус: все ожидающие transition-обновления React рендерит одной пачкой. В очереди listQuery лежат и “1”, и “12” — значит, считается сразу список для “12”. Список для “1” не появится на экране вообще.

  • inputQuery: "12";

  • listQuery: "12";

  • на экране: в поле “12”, под ним товары с “12” в названии;

  • WIP-дерево: нет

Выбрасывать WIP-дерево безопасно: пока идёт рендер, экран не меняется, а в DOM попадает только законченный результат.

Вывод для практики. Промежуточные значения несрочного состояния могут так и не попасть на экран. Не стройте логику на том, что каждое значение listQuery будет отрисовано, — например, аналитику “пользователь увидел результаты для X” в эффекте на listQuery.


7. Как обновления не путаются в порядке

Смотрим на путь от сеттера до рендера: как обновления встают в очередь хука и в каком порядке рендер их применяет.

Добавим на страницу кнопку “Очистить”. Она сбрасывает оба состояния, и оба — срочно, без startTransition:

<button onClick={() => { setInputQuery(""); setListQuery(""); }}>
  Очистить
</button>

Пользователь напечатал “1”, и список для “1” уже на экране. Потом он допечатал “2”, и setListQuery("12") ждёт своего несрочного рендера. Не дождавшись, пользователь жмёт “Очистить”.

В очереди хука listQuery теперь два обновления:

обновление

приоритет

setListQuery("12")

несрочный

setListQuery("")

срочный

Срочный рендер. React просматривает всю очередь по порядку и у каждого обновления проверяет приоритет. Несрочный setListQuery("12") по приоритету не подходит — перепрыгиваем. Срочный setListQuery("") берем в работу. На экране: пустое поле и полный список. Правильно.

Несрочный рендер. Теперь подходит и setListQuery("12"). После срочного рендера listQuery уже "". Если бы React просто применил setListQuery("12") поверх этого, listQuery стал бы "12": setListQuery("12") не смотрит на прежнее значение, а просто заменяет его. И список внезапно отфильтровался бы по тексту, который пользователь уже стёр. Баг.

Чтобы такого не было, у хука, кроме текущего значения состояния, есть ещё два поля:

  • memoizedState - текущее значение состояния, его и видит компонент;

  • baseState - база: значение состояния, с которого следующий рендер начнёт применять обновления;

  • baseQueue - все обновления, начиная с первого пропущенного, в том порядке, в каком вызывались сеттеры: и пропущенные, и уже применённые. Следующий рендер применит их к базе по порядку.

Проследим, как менялись эти поля с самого начала у хука listQuery - того useState, в котором хранится текст для списка.

Начало. Поле пустое, на экране полный список:

  • memoizedState: "";

  • baseState: "";

  • baseQueue: пусто

Напечатана “1”. Сам сеттер эти поля не меняет — он только ставит обновление в очередь. Поля пересчитываются, когда React вызывает компонент и хук разбирает свою очередь. Первым это делает срочный рендер от setInputQuery("1"): оба состояния, inputQuery и listQuery, объявлены в одном компоненте — Search, и React вызывает его целиком. Хук listQuery находит в очереди несрочное обновление setListQuery("1") и пропускает его. На первом пропуске React фиксирует базу — значение до пропуска, "", - и откладывает это обновление в baseQueue. С этого момента baseState не следует за memoizedState, пока пропущенное не применят:

  • memoizedState: "";

  • baseState: "";

  • baseQueue: setListQuery("1") (несрочное)

Несрочный рендер для “1”. React начинает с базы "" и применяет setListQuery("1"). Пропусков нет, поэтому baseQueue очищается, а в baseState React записывает то же, что в memoizedState. Список для “1” на экране:

  • memoizedState: "1";

  • baseState: "1";

  • baseQueue: пусто

Напечатана “2”. Всё как с “1”: срочный рендер поля пропускает несрочное обновление setListQuery("12"), база фиксируется на "1", а само обновление уходит в baseQueue:

  • memoizedState: "1";

  • baseState: "1";

  • baseQueue: setListQuery("12") (несрочное)

Нажата “Очистить”. Кнопка вызывает setListQuery("") без startTransition - это срочное обновление. Срочный рендер снова перепрыгивает через setListQuery("12"), а setListQuery("") применяет. Но setListQuery("") идёт в очереди после пропущенного setListQuery("12"), поэтому тоже попадает в baseQueue - хоть и уже применён.

В этом и смысл правила: baseQueue хранит обновления в исходном порядке вызовов, независимо от приоритета. Несрочный рендер прогонит их от базы по порядку — так, как если бы приоритетов не было вовсе. Только применённому setListQuery("") React в baseQueue ставит приоритет 0 (NoLane). Нулевой приоритет React считает подходящим для любого рендера, так что такое обновление больше никогда не пропустят. В исходниках так и написано: “это обновление будет показано, мы не хотим его откатывать” (This update is going to be committed so we never want uncommit it).

Поля после клика:

  • memoizedState: "";

  • baseState: "1";

  • baseQueue: setListQuery("12") (несрочное), setListQuery("") (приоритет 0)

После несрочного рендера. React начинает не с memoizedState, а с базы и прогоняет baseQueue по порядку: "1" → "12" → "". Итог — "", как будто всё шло по порядку с самого начала.

  • memoizedState: "";

  • baseState: "";

  • baseQueue: пусто

В коде React это называют rebasing — как в git: отложенные изменения заново накатываются на базу.

Вывод для практики. Порядок вызовов setState сохраняется, даже если у них разный приоритет, — ничего делать не нужно. Но между рендерами на экране может быть промежуточное состояние: “срочные уже применены, несрочные — ещё нет”.


8. Приоритеты — это биты

Смотрим на путь от сеттера до рендера: как React помечает, какая работа ждёт и насколько она срочная.

Приоритет в React — не число 1, 2, 3, а бит в 31-битном числе. Такие биты называются lanes (“полосы”). Чем младше бит, тем срочнее: клик и ввод, непрерывные события вроде скролла, обычные обновления, 14 бит под transition, биты под Suspense и фоновую работу.

Набор приоритетов — одно число, поэтому всё считается одной операцией:

  • входит ли обновление в рендер: (set & lane) === lane;

  • объединить наборы:a | b;

  • самый срочный: lanes & -lanes, младший бит.

Отсюда и то, что нулевой приоритет подходит любому рендеру: (set & 0) === 0 верно при любом set.

Группы и ротация. Для transition в React не один бит, а целая группа: 10 бит под startTransition и ещё 4 под useDeferredValue. Так же устроены повторы Suspense — тоже 4 бита. Когда React выбирает, что рендерить, он берёт сразу все ожидающие биты группы — поэтому все transition-обновления и рендерятся одной пачкой.

Зачем тогда несколько бит, а не один? Чтобы различать transition между собой. Например, если один из них ждёт данных в Suspense, React помечает его бит как приостановленный и рендерит остальные, не дожидаясь его.

Биты в группе раздаются по кругу. Каждое новое событие со startTransition получает следующий бит, а после последнего React возвращается к первому. Внутри одного события бит один: два startTransition в одном обработчике попадут в один бит. А если по кругу выпал бит, который ещё занят старым transition, новый просто разделит его со старым — и рендериться они будут вместе.

Биты помогают и не обходить всё дерево. Когда меняется компонент, React поднимается от него к корню и ставит каждому предку бит “внизу есть работа” (childLanes). При рендере ветку без нужного бита React пропускает целиком, не заходя внутрь.

Почему 31, а не 32. Старший, 32-й бит в JavaScript означает минус, и React его просто не занимает: любой набор приоритетов — положительное число. Объяснения этому в исходниках я не нашёл — видимо, так просто проще, чем следить за знаком.


Итог

  • Как остановить рендер? React рендерит по одному компоненту и после каждого спрашивает планировщика, не пора ли уступить. Если пора — выходит через return и оставляет планировщику колбек, чтобы потом продолжить. Планировщик отправляет сам себе сообщение и тоже выходит — стек пустеет. Сообщение приходит отдельной задачей (макротаской), а между задачами браузер успевает обработать ввод и нарисовать кадр. Только потом планировщик вызывает колбек.

  • Почему только с React 16? Раньше React обходил дерево рекурсией, и где он сейчас в дереве, помнил только стек вызовов: выйти посередине значило потерять место. В React 16 у каждого компонента появился объект-узел — fiber — со ссылками на первый дочерний компонент, соседний и родительский. По таким ссылкам дерево обходится обычным циклом, а текущий узел хранится в одной переменной. Её можно оставить как есть, выйти и потом продолжить с того же места.

  • Что прерывается? Только несрочные рендеры: обновления из startTransition, useDeferredValue и повторы Suspense. Рендер после клика, ввода или обычного setState идёт целиком, без пауз.

  • Что с недоделанным WIP-деревом? Выбрасывается; несрочное считается заново, все transition — одной пачкой.

  • Как не путается порядок? База + отложенная очередь: несрочный рендер переигрывает обновления по порядку вызова.

  • Что такое приоритет? Бит в 31-битной маске (lane).

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

  • Если после обновления тяжёлый рендер, оберните это обновление в startTransition. Иначе React отрендерит всё одним куском, и ввод будет ждать.

  • Если тяжёлый расчёт сидит в одном компоненте, нарезка по 5 мс не поможет. Выносите такой расчёт из рендера — например, в Web Worker.

  • Не делайте в теле компонента запросов, подписок и изменений внешних переменных. React может вызвать компонент и выбросить результат, а эти действия всё равно произойдут.

  • Не рассчитывайте, что каждое промежуточное значение несрочного состояния дойдёт до экрана: недостроенный рендер может быть выброшен.

За кадром осталось немало. Что будет, если несрочный рендер прерывают снова и снова, — список так и не дойдёт до экрана? Как useDeferredValue показывает старое значение, пока считается новое? И как transition держит на экране старый интерфейс, пока Suspense ждёт данных? Об этом — в следующий раз.

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