JavaScript в браузере исполняется в однопоточном цикле событий (Event Loop). При традиционном синхронном рендеринге выполнение тяжелого компонента монопольно блокирует единственный поток: если расчет графа или отрисовка списка из 10 000 элементов занимает 120 мс, браузер не способен обработать ни один клик мыши, нажатие клавиши или прокрутку страницы.
Это приводит к замиранию интерфейса (UI Freeze) и катастрофической деградации ключевой метрики Core Web Vitals — INP (Interaction to Next Paint).
Исторически инженеры пытались решить эту проблему обходными путями: дебаунсингом (debounce), троттлингом (throttle) или выносом расчетов в Web Workers. Однако эти подходы либо вводят принудительную искусственную задержку отклика, либо требуют дорогой сериализации данных через postMessage.
Concurrent Rendering (Конкурентный рендеринг) в React 19 решает задачу на фундаментальном уровне планировщика задач ядра. Это механизм прерываемой многозадачности (Preemption), позволяющий React работать над несколькими версиями дерева интерфейса одновременно. Рендеринг больше не является монолитной блокирующей операцией: он квантуется по времени (Time-Slicing), а обновления разделяются на два концептуальных потока:
input, клики, фокус), требующие мгновенного визуального подтверждения.Если во время рендеринга низкоприоритетного перехода пользователь нажимает клавишу, React немедленно вытесняет текущий фоновый рендер, синхронно обновляет инпут и лишь затем возвращается к тяжелым вычислениям.
Конкурентный планировщик React использует квантование времени с 5-миллисекундными окнами. Для реализации кооперативной многозадачности внутри браузера используется MessageChannel, создающий макротаски без задержки таймера.
Синхронный блокирующий рендер (React 15-17):
[ Пользователь печатает "A" ]
│
▼
[ Блокирующий рендер 100ms: ============================== ] ──► Кадр заморожен (Drop FPS)
Конкурентный рендер с Time-Slicing и вытеснением (React 19):
[ Пользователь печатает "A" ]
│
▼
[Квант 5ms] ──► [Квант 5ms] ──► [ Нажата клавиша "B"! ] (Urgent Task)
│
▼
[Прерывание фонового рендера]
[Срочный рендер ввода: 2ms] ──► Отрисовка кадра 120 FPS
│
▼
[Возобновление рендера "AB" в фоне]Когда компонент оборачивает изменение состояния в startTransition, React переключает генерацию полос приоритетов на диапазон TransitionLanes. В процессе сборки дерева workInProgress движок непрерывно опрашивает shouldYield(). Если в очереди Event Loop появляется срочное событие ввода, текущее дерево workInProgress замораживается или сбрасывается, уступая процессор браузеру.
В модели человеческого восприятия отклик на нажатие клавиши или клик обязан произойти быстрее 100 мс (в идеале — за 8–16 мс одного кадра дисплея). Обновление результатов фильтрации или рендеринг таблицы может занять 300–500 мс без малейшего дискомфорта для пользователя, если сам интерфейс ввода не фризит и отзывчив к нажатиям.
Обработчик запускает функцию обновления. React активирует контекст низкого приоритета TransitionLanes.
React синхронно выставляет isPending = true, позволяя компоненту немедленно отобразить индикатор загрузки или прозрачность.
Запускается workLoopConcurrent. Движок строит workInProgress дерево квантами по 5 мс через MessageChannel, периодически возвращая поток браузеру.
Если поступает новое событие клавиатуры или клика, фоновый рендер мгновенно прерывается, уступая место синхронному обновлению SyncLane.
После завершения формирования кадра дерево фиксируется в реальном DOM, а флаг isPending атомарно переключается в false.
Что физически происходит в основном потоке браузера, когда во время выполнения фонового перехода startTransition пользователь нажимает клавишу на клавиатуре?
Пользователь кликает на элемент или печатает текст. React перехватывает событие через синтетическую систему делегирования.
Прямой ввод помечается как SyncLane (приоритет 1). Код внутри startTransition помечается битовой маской TransitionLane.
Если рендерится Transition, но поступает SyncLane, WorkLoop сбрасывает незавершенное дерево и немедленно обслуживает SyncLane.
После завершения срочного ввода фоновый рендер перезапускается с учетом самых актуальных данных и фиксируется в DOM.
В React 19 хук useTransition получил фундаментальное расширение: функция-обертка startTransition официально поддерживает асинхронные функции (Async Transitions). Флаг isPending остается активным на протяжении всей цепочки await, устраняя необходимость в громоздких связках useState(false).
import React, { useState, useTransition } from 'react';
interface Product {
id: string;
name: string;
category: string;
price: number;
}
interface ProductCatalogProps {
initialProducts: Product[];
}
export function EnterpriseProductCatalog({ initialProducts }: ProductCatalogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [filteredProducts, setFilteredProducts] = useState(initialProducts);
const [isPending, startTransition] = useTransition();
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextQuery = event.target.value;
// 1. СРОЧНОЕ ОБНОВЛЕНИЕ: Синхронное отражение ввода в поле инпута (SyncLane)
setSearchTerm(nextQuery);
// 2. АСИНХРОННЫЙ ПЕРЕХОД: Тяжелая фильтрация в фоновом потоке (TransitionLane)
startTransition(async () => {
// Имитация сложной фильтрации на клиенте или сетевого вызова
const results = initialProducts.filter(item =>
item.name.toLowerCase().includes(nextQuery.toLowerCase())
);
setFilteredProducts(results);
});
};
return (
<div className="catalog-container p-6">
<div className="search-header flex items-center gap-4 mb-6">
<input
type="text"
value={searchTerm}
onChange={handleSearchChange}
placeholder="Поиск по 50 000 товаров..."
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-cyan"
/>
{isPending && (
<span className="text-xs text-text-muted animate-pulse font-mono">
Обновление результатов...
</span>
)}
</div>
<div className={`products-grid transition-opacity duration-200 ${isPending ? 'opacity-60' : 'opacity-100'}`}>
<p className="text-sm text-text-muted mb-4">Найдено позиций: {filteredProducts.length}</p>
<div className="grid grid-cols-3 gap-4">
{filteredProducts.map(product => (
<div key={product.id} className="p-4 border border-border rounded-md bg-bg-surface">
<h4 className="font-semibold">{product.name}</h4>
<p className="text-accent-cyan">${product.price}</p>
</div>
))}
</div>
</div>
</div>
);
}Хук useDeferredValue позволяет компоненту отложить обновление производного значения до тех пор, пока срочные обновления не будут отрисованы.
Внутренняя механика useDeferredValue:
value React сначала выполняет рендер, в котором useDeferredValue возвращает предыдущее значение. Срочный ввод (каретка инпута) отображается с нулевой задержкой.TransitionLane, в котором useDeferredValue возвращает новое значение.initialValue, позволяющий определить начальное значение до первичного монтирования компонента.import React, { useState, useDeferredValue, memo } from 'react';
interface AnalyticsDataPoint {
timestamp: number;
cpuLoad: number;
memoryUsage: number;
}
// Тяжелый компонент графиков, мемоизированный для предотвращения лишних спусков
const HeavyChart = memo(function HeavyChart({ data }: { data: AnalyticsDataPoint[] }) {
const startTime = performance.now();
while (performance.now() - startTime < 40) {
// Искусственная нагрузка CPU 40 мс на каждый рендер
}
return (
<div className="chart-canvas border p-4 rounded bg-bg-elevated">
<p className="text-xs text-text-muted">Обработано точек данных: {data.length}</p>
<div className="h-48 flex items-end gap-1 mt-2">
{data.slice(-50).map((point, idx) => (
<div
key={idx}
style={{ height: `${point.cpuLoad}%` }}
className="w-2 bg-accent-cyan rounded-t-sm"
/>
))}
</div>
</div>
);
});
export function AnalyticsDashboard({ fullHistory }: { fullHistory: AnalyticsDataPoint[] }) {
const [rangeLimit, setRangeLimit] = useState(500);
// Отложенное значение слайдера: ползунок движется со скоростью 120 FPS, график рендерится в фоне
const deferredLimit = useDeferredValue(rangeLimit, 500);
const isStale = rangeLimit !== deferredLimit;
const currentDataSlice = fullHistory.slice(0, deferredLimit);
return (
<div className="analytics-box space-y-4">
<div className="control-panel flex items-center justify-between">
<label className="text-sm font-medium">
Размер выборки: {rangeLimit} записей
</label>
<input
type="range"
min="100"
max="10000"
step="100"
value={rangeLimit}
onChange={e => setRangeLimit(Number(e.target.value))}
className="w-64 cursor-pointer"
/>
</div>
<div className={`chart-wrapper transition-opacity duration-150 ${isStale ? 'opacity-50' : 'opacity-100'}`}>
<HeavyChart data={currentDataSlice} />
</div>
</div>
);
}Почему категорически не рекомендуется оборачивать обновление стейта управляемого поля ввода (setInputValue) напрямую внутрь startTransition?
Для контроля за тем, чтобы фоновые вычисления не деградировали показатель Interaction to Next Paint (INP), создадим утилиту-обертку, профилирующую задержку квантования:
import { useTransition, useCallback, useRef } from 'react';
interface TransitionTelemetry {
durationMs: number;
taskName: string;
isPreempted: boolean;
}
export function useMonitoredTransition(taskName: string) {
const [isPending, rawStartTransition] = useTransition();
const startTimeRef = useRef<number>(0);
const startTransitionWithTelemetry = useCallback(
(action: () => Promise<void> | void) => {
startTimeRef.current = performance.now();
rawStartTransition(async () => {
try {
await action();
} finally {
const totalDuration = performance.now() - startTimeRef.current;
if (totalDuration > 50) {
console.warn(
`[INP Warning] Фоновый переход "${taskName}" занял ${totalDuration.toFixed(1)} мс. Рекомендуется оптимизировать граф Fiber.`
);
}
}
});
},
[rawStartTransition, taskName]
);
return [isPending, startTransitionWithTelemetry] as const;
}Попытка поместить setInputValue(e.target.value) внутрь startTransition приводит к рассинхронизации DOM-состояния и внутренней модели React. Нажатия клавиш начинают запаздывать, курсор каретки прыгает в конец строки, а быстрый набор текста приводит к визуальному джиттеру. Ввод текста обязан оставаться синхронным (Urgent)!
// ❌ ПЛОХО: оборачивание ввода ломает каретку и отзывчивость печати
function BadSearch() {
const [query, setQuery] = useState('');
const [, startTransition] = useTransition();
return (
<input
value={query}
onChange={e => {
startTransition(() => {
setQuery(e.target.value); // Каретка запаздывает!
});
}}
/>
);
}
// ✅ ПРАВИЛЬНО: синхронный ввод + отложенное вторичное состояние
function GoodSearch() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<HeavyList query={deferredQuery} />
</div>
);
}Хук useDeferredValue предотвращает лишний рендеринг тяжелого дочернего дерева только в том случае, если дочерний компонент мемоизирован (React.memo или оптимизирован React Compiler). Если дочерний компонент не мемоизирован, он будет рендериться дважды на каждое нажатие клавиши: сначала со старым значением, затем с новым, удваивая нагрузку на процессор.
// ❌ ПЛОХО: HeavyList не мемоизирован, рендерится впустую дважды
export function SearchPage({ items }: { items: string[] }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<HeavyList filter={deferredFilter} items={items} />
</div>
);
}Попытка объединить setTimeout / lodash.debounce с хуком useDeferredValue создает двойную деградацию пользовательского опыта. Пользователь сначала ждет искусственный тайм-аут 300 мс, после чего React начинает квантовать вычисления. Используйте нативный startTransition или useDeferredValue напрямую: планировщик сам запустит рендер мгновенно, если процессор свободен.
Поскольку фоновые переходы прерываемы и могут перезапускаться движком несколько раз при вытеснении, мутация внешних переменных или прямое обращение к мутабельным объектам внутри колбэка перехода вызовет состояние гонки (Race Conditions) и фантомные баги. Код внутри перехода обязан быть чистым относительно внешних разделяемых ресурсов.