Как React прерывает рендер: конкурентный рендеринг изнутри
- пятница, 30 октября 2026 г. в 00:00:15
Начиная с 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.
Пару слов, чтобы мы вкладывали один и тот же смысл в термины.
У 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, и путь у обновления один и тот же.
Запрос. Сеттер ничего не меняет на экране сразу — он только создаёт запрос на обновление (дальше для краткости — просто обновление). Обновление встаёт в очередь обновлений своего хука. У каждого функционального компонента есть список хуков (связный список; у классов устроено иначе), а у каждого хука — своя такая очередь: тоже связный список, но кольцевой и с указателем на хвост. Ещё обновление получает приоритет — он зависит от того, откуда вызван сеттер: из обработчика клика, из startTransition, из таймера. В коде React приоритет называется lane. Но в очереди приоритет роли не играет: новое обновление всегда встаёт в хвост. Очередь — общий объект для пары узлов в current и WIP-дереве, поэтому обновление не потеряется, даже если WIP-дерево выбросят (такое бывает — об этом ниже).
Планирование. Приоритет есть не только у обновления, но и у каждого узла дерева. У узла два таких поля: lanes - приоритеты обновлений, которые ждут в самом компоненте, и childLanes - приоритеты обновлений, которые ждут в его дочерних компонентах на любой глубине. React записывает приоритет обновления в lanes компонента, а потом поднимается по родительским компонентам до корня и добавляет этот приоритет в childLanes каждого. Вызывать компоненты — то есть рендерить — React при этом сразу не начинает. React ждёт, пока закончится текущий код — например, весь обработчик клика, — чтобы все обновления из него попали в один рендер. И только потом решает, что делать: самое срочное (клик, ввод с клавиатуры) рендерит тут же, а остальное отдаёт планировщику — отдельному пакету React, который решает, когда эту работу выполнить.
Рендер. За один рендер React берёт не все ожидающие обновления, а только самые срочные из них — остальные подождут следующего рендера. React обходит дерево от корня. По childLanes он видит, в каких ветках ждут обновления с этим приоритетом, и спускается только туда, а остальные пропускает. По lanes находит сами компоненты с такими обновлениями. Их и их дочерние компоненты React вызывает заново. Новый результат сравнивается с тем, что лежит в current, и собирается в WIP-дерево. Экран всё это время не меняется.
Коммит. Изменения из готового WIP-дерева применяются к DOM за один раз, без пауз, и WIP-дерево становится current. Поэтому пользователь никогда не видит интерфейс наполовину обновлённым. Дальше браузер рисует страницу.
Теперь, что такое конкурентный рендеринг. Без него React обрабатывает обновления по очереди: взял пачку (несколько обновлений подряд он объединяет в одну), довёл до экрана, взял следующую. Пока идёт тяжёлое обновление, всё остальное ждёт, и интерфейс “замерзает”. Тяжёлое обновление блокирует поток — а в нём же браузер обрабатывает ввод и рисует страницу. Поэтому, пока обновление не закончится, ни нажатие клавиши, ни перерисовка не произойдут.
В конкурентном рендеринге у обновлений появляется приоритет. Срочное — например, ввод с клавиатуры — может обогнать несрочное, даже если то уже наполовину посчитано: React выбрасывает недоделанное WIP-дерево и сначала рендерит срочное. Выбросить его безопасно — экран во время рендера не меняется. В итоге поле ввода отзывается мгновенно, а тяжёлый список догоняет чуть позже. Вся эта конкурентность — в том, когда React начинает рендер и как его прерывает. Коммит же всегда идёт целиком, без пауз.
Страница с поиском по каталогу: поле ввода и список товаров, до 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 считает в памяти, но ещё не показал.
Смотрим на момент, когда пользователь напечатал “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 делает с компонентами.
Смотрим на тот же момент, когда React строит дерево в памяти. Почему его вообще можно бросить на полпути?
До React 16 дерево обходилось рекурсией: функция обрабатывает компонент и вызывает себя для каждого дочернего компонента. Этот механизм так и называют — stack reconciler. Где ты сейчас в дереве, знал только стек вызовов. А стек не отложишь: вышел из функции — позиция потеряна. Поэтому всё дерево обходилось целиком, без остановок.
В React 16 обход переписали. У каждого компонента появился объект — fiber, с тремя ссылками: на первый дочерний компонент (child), на следующий соседний (sibling) и на родительский (return). Дерево стало связным списком, по которому идут обычным циклом: есть дочерний — вниз, нет — к соседнему, нет соседнего — вверх к родительскому и дальше к его соседнему. Чтобы помнить позицию, хватает одной переменной — той самой workInProgress.
Вывод для практики. Представьте: React успел вызвать половину Row для “1”, и тут пользователь напечатал “2”. Недоделанное WIP-дерево React выбрасывает и строит заново — уже для “12”. Получается, многие Row вызваны дважды, а результат первого вызова на экран так и не попал. Поэтому в теле компонента не должно быть действий, которые влияют на что-то снаружи. Например, если отправлять запрос прямо в теле компонента, запросов уйдёт столько, сколько раз React его вызвал, — в том числе для выброшенных рендеров. То же с подписками и с изменением внешних переменных.
Такому коду место в эффектах или в обработчиках событий. Эффекты запускаются только после коммита — то есть только для того, что React действительно применил к DOM. А обработчики срабатывают на действие пользователя, а не на рендер.
Смотрим на момент между вызовом сеттера и рендером: компоненты 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: работы меньше не станет, но её можно будет прервать. Ну, и, опять же, можно уменьшить саму работу: виртуализировать список или загружать данные порциями.
Смотрим на момент, когда список для “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.
Смотрим на путь от сеттера до рендера: как обновления встают в очередь хука и в каком порядке рендер их применяет.
Добавим на страницу кнопку “Очистить”. Она сбрасывает оба состояния, и оба — срочно, без startTransition:
<button onClick={() => { setInputQuery(""); setListQuery(""); }}> Очистить </button>
Пользователь напечатал “1”, и список для “1” уже на экране. Потом он допечатал “2”, и setListQuery("12") ждёт своего несрочного рендера. Не дождавшись, пользователь жмёт “Очистить”.
В очереди хука listQuery теперь два обновления:
обновление | приоритет |
|---|---|
| несрочный |
| срочный |
Срочный рендер. 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 сохраняется, даже если у них разный приоритет, — ничего делать не нужно. Но между рендерами на экране может быть промежуточное состояние: “срочные уже применены, несрочные — ещё нет”.
Смотрим на путь от сеттера до рендера: как 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 ждёт данных? Об этом — в следующий раз.
Если что-то осталось непонятным или хочется копнуть какую-то часть глубже — пишите в комментариях.