Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Конкурентний рендеринг у React». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Конкурентний рендеринг (concurrent rendering) у React** дозволяє React 18 призупинити поточний рендеринг, обробити термінове оновлення першим, а потім відновити або відкинути призупинену роботу. Підключи через `createRoot` і позначай повільні оновлення через `startTransition`: ```tsx const [isPending, startTransition] = useTransition(); startTransition(() => setResults(filter(items, query))); // виконується як низькопріоритетне ``` **Ключове:** введення та кліки завжди переривають transitions. Список наздожене, коли браузер матиме вільний час.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Конкурентний рендеринг (concurrent rendering) у React** - це можливість React 18 зупинити рендеринг посередині, обробити терміновіше оновлення, а потім відновити або викинути призупинену роботу. ## Теорія ### TL;DR - Аналогія: шеф-кухар зупиняє приготування повільного жаркого, щоб швидко подати замовлення новому гостю, а потім повертається. React робить те саме з оновленнями інтерфейсу. - До React 18 кожен рендеринг блокував браузер до завершення. Конкурентний режим ділить роботу на слоти по ~5мс і поступається браузеру між кожним. - Головний API: загорни повільні оновлення стану в `startTransition`. Це покриває 80% реального використання. - Правило вибору: використовуй для додатків із важкими списками, пошуковими фільтрами або складними табами. Статичним сторінкам це не потрібно. ### Швидкий приклад ```jsx import { useState, useTransition } from 'react'; import { createRoot } from 'react-dom/client'; function Search({ items }) { const [query, setQuery] = useState(''); const [filtered, setFiltered] = useState(items); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { setQuery(e.target.value); // терміново: оновлює поле відразу startTransition(() => { // нетерміново: фільтрація 10k елементів не блокує введення setFiltered(items.filter(i => i.includes(e.target.value))); }); }; return ( <div> <input value={query} onChange={handleChange} /> {isPending && <span>Оновлення списку...</span>} <ul>{filtered.map((item, i) => <li key={i}>{item}</li>)}</ul> </div> ); } const root = createRoot(document.getElementById('root')); root.render(<Search items={Array(10000).fill('item')} />); ``` Без `startTransition` введення тексту затримується на 100-500мс на звичайному пристрої. З ним поле реагує миттєво, а список наздоганяє. ### Синхронний рендеринг проти конкурентного До React 18 рендеринг фіксував весь DOM за раз і лише тоді повертав управління браузеру. Вводиш символ поки рендеряться 10 000 елементів? Натискання чекає в черзі. Планувальник React 18 ділить рендеринг на шматки по ~5мс і перевіряє між кожним: чи прийшло щось терміновіше? Якщо так, стара робота відкидається. React стартує заново з актуальним значенням і фіксує лише цей результат. ### Коли використовувати - Пошук або фільтрація по великих даних: `startTransition` тримає поле введення чуйним поки список оновлюється у фоні. - Переключення табів із важким вмістом: познач рендеринг нового таба як transition, щоб поточний вигляд не завис. - Швидке перемикання між елементами в дропдауні або списку юзерів: конкурентний режим автоматично відкидає застарілі рендери. - Статичні сторінки або прості дашборди: синхронний рендеринг підходить. Зайвого оверхеду не потрібно. - Легасі-кодова база: додавай конкурентні можливості лише там, де профайлінг показав реальні проблеми. ### Як це працює всередині React 18 призначає кожному оновленню "лейн" (lane) - 32-бітну бітову маску, де нижчі значення означають вищий пріоритет. Натискання клавіші потрапляє у `InputContinuousLane`. Оновлення через `startTransition` - у `TransitionLane`. Планувальник перевіряє `shouldYield()` приблизно кожні 5мс, що відповідає бюджету одного кадру при 60fps. Якщо приходить пріоритетніше оновлення, React зупиняє поточну побудову fiber-дерева, зберігає незавершений альтернативний варіант у пам'яті і стартує заново. Коли термінова робота зафіксована, React або відновлює призупинену, або викидає її, якщо стан змінився. Часткових фіксацій не буває. DOM завжди відображає останній завершений рендеринг. На сервері це не працює так само: Node.js не має головного потоку, який поступається подіям браузера. Для SSR використовуй `renderToPipeableStream` із Suspense - він надсилає HTML частинами в міру розв'язання меж Suspense. ### Конкурентні можливості: короткий огляд | Можливість | Що робить | API | |---|---|---| | Transitions | Позначає оновлення як переривані й низькопріоритетні | `useTransition`, `startTransition` | | Відкладені значення | Затримує повторний рендеринг похідного значення | `useDeferredValue` | | Автоматичний батчинг | Групує всі оновлення стану в один рендеринг, навіть у асинхронному коді | Вбудовано в React 18 | | Потоковий SSR | Відправляє HTML частинами по мірі готовності даних | `Suspense` + `renderToPipeableStream` | | Вибіркова гідратація | Гідратує частини сторінки незалежно одна від одної | `Suspense` на клієнті | ### Підключення ```jsx // React 18: один рядок вмикає всі конкурентні можливості import { createRoot } from 'react-dom/client'; const root = createRoot(document.getElementById('root')); root.render(<App />); // Старий API: повністю синхронний, без конкурентних можливостей // ReactDOM.render(<App />, document.getElementById('root')); ``` Одна ця зміна відкриває доступ до всього. Автоматичний батчинг (automatic batching) починає працювати одразу. `useTransition`, `useDeferredValue` і `Suspense` підключаються окремо там, де потрібні, і не впливають на решту коду. ### Типові помилки **1. Загортати прості оновлення в `startTransition`.** ```jsx // немає сенсу: лічильник оновлюється миттєво і без того startTransition(() => setCount(count + 1)); ``` Transitions додають оверхед: відстеження pending-стану, обробку переривань, роботу з альтернативним деревом. На тривіальних оновленнях це може додати 10-20% зайвої роботи без жодного візуального ефекту. Спочатку профайл, потім transition. **2. Ігнорувати `isPending`.** ```jsx // погано: користувач бачить застарілий список і думає що щось зависло <input onChange={e => startTransition(() => setFilter(e.target.value))} /> ``` Під час transition старий вміст залишається на екрані. Без спінера або будь-якого індикатора завантаження користувач думає, що застосунок завис. Завжди перевіряй `isPending` і показуй зворотний зв'язок. **3. Викликати `flushSync` у циклі.** ```jsx // повертає поведінку React 17: блокує браузер на кожній ітерації items.forEach(item => flushSync(() => updateItem(item))); ``` `flushSync` змушує синхронну фіксацію і обходить планувальник. У циклі кожна ітерація блокує браузер. Використовуй його один раз після циклу, якщо дійсно потрібна негайна фіксація. **4. Розраховувати на порядок виконання transitions.** Конкурентний режим пріоритизує за лейном, а не за порядком виклику `startTransition`. Два швидких transitions можуть завершитися так, що зафіксується лише другий. Роби оновлення стану ідемпотентними і тестуй із `act` у Jest. Найчастіша помилка з усіх - друга. Додати `isPending` постфактум - це найшвидший фікс для "чому мій UI виглядає зламаним", який я бачив у коді, де все решта було написано правильно. ### Де зустрічається - Next.js 13+ App Router вмикає конкурентний рендеринг за замовчуванням для React Server Components і стримінгу - TanStack Query `useSuspenseQuery` інтегрується з boundaries Suspense і transitions для UX при завантаженні даних - React DevTools Profiler показує призначення лейнів і часові слоти, щоб бачити які рендери були перервані - Продакшен-стрічка Meta обробляє нескінченний скрол і живе введення тексту одночасно через конкурентний планувальник ### Follow-up питання **Q:** Що таке "лейн" (lane) у планувальнику React? **A:** 32-бітна бітова маска, що кодує пріоритет оновлення. Нижчі значення означають вищий пріоритет. `InputContinuousLane` має перевагу над `TransitionLane`. React групує оновлення з однаковим лейном і обробляє групу з найвищим пріоритетом першою. **Q:** Чим `startTransition` відрізняється від `flushSync`? **A:** Вони протилежні. `startTransition` позначає роботу як низькопріоритетну й переривану. `flushSync` змушує синхронну фіксацію до повернення. Якщо використати обидва для одного оновлення, transition скасовується. **Q:** Скільки часу займає один слот (time slice) у конкурентному режимі? **A:** Приблизно 5мс, виміряно через `Scheduler.unstable_shouldYield()`. Це вписується в бюджет кадру 16мс при 60fps, залишаючи місце для відмальовки браузера. **Q:** Що відбувається з рендерингом, який відкидається? **A:** Альтернативне fiber-дерево збирається сміттєзбирачем. React ніколи не фіксує часткову роботу, тому DOM завжди відображає повний завершений рендеринг. **Q:** Чому конкурентний рендеринг не працює так само в SSR? **A:** Node.js не має головного потоку, який поступається подіям введення. Серверний еквівалент - `renderToPipeableStream`, який стримить HTML-шматки до клієнта в міру розв'язання boundaries Suspense. **Q:** Як реалізувати yielding у кастомному рендерері без `requestIdleCallback`? **A:** Полімorphism через `setTimeout(0)` і чергу з пріоритетами. Вимірюй бюджет кадру через `performance.now()` до і після кожної одиниці роботи і поступайся, коли бюджет вичерпано. Підключайся до `scheduleCallback` з пакету Scheduler - це те, що хочуть почути інтерв'юери на цьому рівні. ## Приклади ### Базовий: підключення createRoot ```jsx import { createRoot } from 'react-dom/client'; import App from './App'; const root = createRoot(document.getElementById('root')); root.render(<App />); ``` Одна заміна замість `ReactDOM.render`. Після цього автоматичний батчинг React 18 активний, а `useTransition` доступний у будь-якому компоненті дерева. ### Середній: пошук по todo-списку з індикатором завантаження ```jsx import { useState, useTransition } from 'react'; function TodoSearch({ todos }) { const [query, setQuery] = useState(''); const [results, setResults] = useState(todos); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { const value = e.target.value; setQuery(value); // завжди миттєво startTransition(() => { setResults(todos.filter(todo => todo.text.includes(value))); }); }; return ( <div> <input value={query} onChange={handleChange} placeholder="Пошук..." /> {isPending ? <p>Фільтрація...</p> : null} <ul> {results.map(todo => <li key={todo.id}>{todo.text}</li>)} </ul> </div> ); } ``` `isPending` стає `true` одразу після виклику `startTransition` і повертається до `false` коли відфільтрований список зафіксований у DOM. Це вікно для показу спінера або скелетона. ### Senior: конкурентний рендеринг при швидкому перемиканні профілів ```jsx import { useState, useTransition, Suspense } from 'react'; function UserProfile({ userId }) { const [profile, setProfile] = useState(null); const [isPending, startTransition] = useTransition(); const loadProfile = (id) => { startTransition(async () => { const data = await fetchProfile(id); setProfile(data); // якщо userId змінився під час запиту, React відкине цей рендеринг }); }; return ( <div style={{ opacity: isPending ? 0.6 : 1 }}> <Suspense fallback={<div>Завантаження профілю...</div>}> {profile ? ( <ProfileCard data={profile} /> ) : ( <button onClick={() => loadProfile(userId)}>Завантажити профіль</button> )} </Suspense> </div> ); } ``` Якщо користувач швидко клікає по п'яти різних профілях, React відкидає чотири проміжних рендеринги і фіксує лише останній. До React 18 для цього потрібно було вручну відстежувати ID поточного запиту і ігнорувати застарілі відповіді. Конкурентний режим прибирає цей патерн у більшості випадків.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.