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 одночасно готувати кілька версій дерева компонентів. Рендеринг більше не є монолітною блокуючою операцією: обчислення квантуються порціями по 5 мс (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(). Якщо в черзі подій з'являється термінове введення, поточне дерево workInProgress заморожується або скидається, поступаючись процесором браузеру.
У моделі людського сприйняття відгук на натискання клавіші чи клік має відбутися швидше ніж за 100 мс (в ідеалі — за 8–16 мс одного кадру дисплея). Фільтрація каталогу чи рендеринг складної діаграми може тривати 300–500 мс без жодного дискомфорту для користувача, якщо поле введення залишається повністю чутливим і не замерзає.
Обробник події викликає startTransition. 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, позбавляючи від необхідності писати громіздкі конструкції const [loading, setLoading] = 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. ТЕРМІНОВЕ ОНОВЛЕННЯ: Синхронне відображення введення в полі input (SyncLane)
setSearchTerm(nextQuery);
// 2. АСИНХРОННИЙ ПЕРЕХІД: Важка фільтрація у фоновому потоці (TransitionLane)
startTransition(async () => {
// Імітація складної клієнтської фільтрації або запиту до API
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. Натискання клавіш починають запізнюватися, каретка перескакує в кінець рядка, а швидке введення створює візуальний фриз. Введення тексту зобов'язане відбуватися синхронно на SyncLane!
// ❌ ПОГАНО: відкладання прямого введення ламає каретку та плавності друку
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 безпосередньо: планувальник виконає рендер миттєво, щойно CPU звільниться.
Оскільки фонові переходи перериваються і можуть повторюватися рушієм кілька разів, зміна зовнішніх мутабельних об'єктів у колбеку переходу спричиняє стан гонки (Race Conditions) та неочікувані баги. Код переходу має залишатися чистим.