Пошук уроків, статей та іншого контенту
Пояснимо модель Concurrent Rendering і те, як React розподіляє роботу, не блокуючи взаємодію користувача.
Concurrent Rendering — це модель React, у якій рендеринг може бути перерваний, призупинений і продовжений пізніше.
У класичній синхронній моделі React починає обчислювати оновлення і намагається завершити весь render-фазис за один раз. Якщо дерево велике або обчислення складні, браузер може надовго не отримувати можливість обробити введення, натискання чи прокручування.
Concurrent Rendering дає React змогу:
розділяти роботу на частини;
тимчасово переривати менш пріоритетний рендеринг;
спочатку обробляти термінові дії користувача;
відкидати незавершений результат, якщо з’явилися новіші дані;
комітувати зміни в DOM лише тоді, коли результат готовий.
Важливо: concurrent rendering не означає виконання JavaScript у кількох потоках. React не запускає компоненти паралельно. Він використовує кооперативне планування: перериває власну роботу між окремими частинами обчислення, щоб браузер міг обробити важливі події.
Оновлення React умовно складається з двох фаз.
Під час render-фази React:
викликає компоненти;
обчислює нове дерево елементів;
порівнює його з попереднім деревом;
У concurrent-режимі ця фаза може:
перериватися;
виконуватися кілька разів;
повністю скасовуватися;
запускатися знову з новішим станом.
Тому render-фаза має бути чистою. Виклик компонента не повинен змінювати зовнішні дані, виконувати побічні ефекти або покладатися на те, що він буде викликаний лише один раз.
Під час commit-фази React застосовує готові зміни:
оновлює DOM;
запускає useLayoutEffect;
планує запуск useEffect;
оновлює ref.
Commit-фаза є атомарною для відповідного оновлення: користувач не побачить половину нового дерева. React або застосує підготовлений результат, або не застосує його взагалі.
Отже, перериватися може render-фаза, але не частково виконаний commit.
Concurrent-можливості використовуються в сучасному API створення root:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root");
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>
);createRoot вмикає сучасну модель React. Старий виклик ReactDOM.render належить до попередньої моделі й не вмикає всі сучасні concurrent-можливості.
Сам факт використання createRoot не робить кожне оновлення асинхронним або низькопріоритетним. React класифікує оновлення за їхнім призначенням і може застосовувати різні пріоритети.
Не всі оновлення однаково важливі для користувача.
Наприклад, під час введення тексту:
Значення поля має оновитися негайно.
Результати фільтрації великого списку можуть оновитися трохи пізніше.
Якщо обидві операції мають однаковий пріоритет, важкий рендеринг списку може заважати введенню. Concurrent Rendering дає змогу повідомити React, що друга операція не є терміновою.
Типова модель має такі категорії:
термінові оновлення — введення в поле, натискання кнопки, переміщення курсора;
transition-оновлення — оновлення інтерфейсу, яке може бути відкладене;
призупинені оновлення — наприклад, рендеринг, який очікує на дані через Suspense.
React не гарантує точний час виконання низькопріоритетного оновлення. Він лише отримує інформацію про те, що інше оновлення важливіше.
startTransitionФункція startTransition позначає оновлення всередині callback як непершочергові:
import { startTransition } from "react";
startTransition(() => {
setFilteredItems(nextItems);
});У цьому прикладі setFilteredItems все одно змінить стан, але React може відкласти відповідний рендеринг, якщо в цей момент потрібно обробити термінову взаємодію.
import { useMemo, useState, startTransition } from "react";
const products = Array.from({ length: 50000 }, (_, index) => ({
id: index + 1,
name: `Product ${index + 1}`,
}));
export default function ProductSearch() {
const [query, setQuery] = useState("");
const [visibleQuery, setVisibleQuery] = useState("");
const filteredProducts = useMemo(() => {
const normalizedQuery = visibleQuery.trim().toLowerCase();
if (!normalizedQuery) {
return products;
}
return products.filter((product) =>
product.name.toLowerCase().includes(normalizedQuery)
);
}, [visibleQuery]);
function handleChange(event) {
const nextQuery = event.target.value;
// Введений текст має оновитися негайно.
setQuery(nextQuery);
// Фільтрація великого списку може виконатися пізніше.
startTransition(() => {
setVisibleQuery(nextQuery);
});
}
return (
<main>
<label htmlFor="product-search">Пошук продуктів</label>
<input
id="product-search"
value={query}
onChange={handleChange}
placeholder="Введіть назву"
/>
<p>
Знайдено: {filteredProducts.length}
</p>
<ul>
{filteredProducts.slice(0, 100).map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</main>
);
}У цьому прикладі є два стани:
query — значення текстового поля, яке має відображатися без затримки;
visibleQuery — значення, за яким обчислюється великий список.
Якщо користувач швидко вводить текст, React може:
негайно оновити поле;
перервати обчислення списку;
обробити наступний символ;
продовжити лише найактуальніше transition-оновлення.
Необов’язково, що кожне проміжне значення буде відрендерене. Якщо користувач уже ввів новий текст, React може відкинути роботу для попереднього значення.
useTransitionuseTransition має ту саму ідею, але додатково повідомляє, чи є transition-робота незавершеною:
const [isPending, startTransition] = useTransition();Повертаються:
isPending — true, поки transition-оновлення очікує або виконується;
startTransition — функція для позначення оновлення як transition.
import { useState, useTransition } from "react";
const tabs = {
overview: "Огляд компонента",
settings: "Налаштування компонента",
activity: "Історія активності",
};
export default function Dashboard() {
const [activeTab, setActiveTab] = useState("overview");
const [isPending, startTransition] = useTransition();
function selectTab(tab) {
startTransition(() => {
setActiveTab(tab);
});
}
return (
<section>
<nav>
{Object.keys(tabs).map((tab) => (
<button
key={tab}
type="button"
onClick={() => selectTab(tab)}
disabled={isPending && activeTab !== tab}
>
{tab}
</button>
))}
</nav>
{isPending && <p>Завантаження розділу…</p>}
<article>
<h2>{activeTab}</h2>
<p>{tabs[activeTab]}</p>
</article>
</section>
);
}isPending корисний для:
індикатора завантаження;
тимчасового стилю;
блокування повторної дії;
пояснення користувачу, що інтерфейс ще оновлюється.
Стан isPending не означає, що мережевий запит обов’язково виконується. Він показує стан transition-рендерингу.
useDeferredValueuseDeferredValue дає змогу отримати відкладену версію вже наявного значення:
const deferredQuery = useDeferredValue(query);На відміну від startTransition, тут не потрібно вручну обгортати виклик setter. Компонент спочатку отримує нове термінове значення, а React пізніше оновлює його відкладену копію.
import { useDeferredValue, useMemo, useState } from "react";
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
const normalizedQuery = deferredQuery.trim().toLowerCase();
if (!normalizedQuery) {
return [];
}
return Array.from({ length: 10000 }, (_, index) => {
const value = `Result ${index + 1}`;
return value.toLowerCase().includes(normalizedQuery)
? value
: null;
}).filter(Boolean);
}, [deferredQuery]);
const isStale = query !== deferredQuery;
return (
<section aria-busy={isStale}>
{isStale && <p>Оновлюємо результати…</p>}
<p>Знайдено: {results.length}</p>
<ul>
{results.slice(0, 50).map((result) => (
<li key={result}>{result}</li>
))}
</ul>
</section>
);
}
export default function Search() {
const [query, setQuery] = useState("");
return (
<main>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Пошук"
/>
<SearchResults query={query} />
</main>
);
}useDeferredValue зручний, коли:
значення вже передається дочірньому компоненту;
дочірній компонент важко змінити;
потрібно залишити поле або іншу термінову частину інтерфейсу чутливою;
доречно показувати старі результати, поки нові ще обчислюються.
Відкладене значення не є debounce. Debounce чекає певний проміжок часу перед виконанням функції. useDeferredValue не використовує фіксований таймер: React сам планує оновлення залежно від доступного часу та пріоритетів.
Припустімо, React почав рендерити результати для запиту re. До завершення користувач вводить react.
React може:
перервати рендеринг для re;
отримати нове значення react;
відкинути незавершений результат;
почати або продовжити рендеринг для react;
закомітити лише актуальний результат.
Це одна з основних переваг concurrent-моделі: React не зобов’язаний доводити до кінця роботу, яка вже втратила актуальність.
Однак React не скасовує автоматично зовнішні побічні ефекти. Якщо код уже виконав мережевий запит, сам факт переривання render-фази не припиняє цей запит. Керування такими операціями належить до відповідної логіки ефектів і скасування запитів.
Оскільки React може викликати компонент кілька разів або відкинути результат, render-фаза повинна залишатися чистою.
Небезпечний приклад:
let renderedItems = 0;
function List({ items }) {
renderedItems += items.length;
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}Змінна renderedItems змінюється під час виклику компонента. Якщо React повторить рендеринг або перерве його, значення може не відповідати кількості реально закомічених елементів.
Краще виконувати побічні дії після commit-фази:
import { useEffect } from "react";
function List({ items, onRendered }) {
useEffect(() => {
onRendered(items.length);
}, [items, onRendered]);
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}Тепер onRendered викликається після того, як відповідний результат був закомічений.
Transition не змінює значення стану на особливий тип стану. Наприклад:
startTransition(() => {
setTab("settings");
});Після завершення оновлення tab буде звичайним рядком "settings". Особливим є не саме значення, а пріоритет оновлення, яке до нього приводить.
Не слід використовувати transition для дій, які безпосередньо контролюють введення:
function handleChange(event) {
startTransition(() => {
setInputValue(event.target.value);
});
}Кероване поле введення повинно оновлюватися терміново. Затримка такого стану може зробити поле повільним або створити відчуття, що натиснуті клавіші ігноруються.
Правильніше розділити термінове значення поля та важкий похідний інтерфейс.
Concurrent Rendering також дає React змогу працювати з UI, який тимчасово не готовий до відображення.
Якщо частина дерева призупиняється, React може:
залишити поточний інтерфейс на екрані;
продовжити роботу над новим деревом;
показати fallback у відповідній межі Suspense;
закомітити готовий результат, коли він стане доступним.
Для transition-оновлення це особливо важливо: перехід між екранами може не замінювати вже видимий інтерфейс fallback-екраном занадто рано. React отримує можливість підготувати новий екран, не блокуючи поточний.
Concurrent Rendering не завантажує дані автоматично і не робить будь-який асинхронний код сумісним із Suspense. Потрібне джерело даних або бібліотека, яка підтримує відповідну модель.
У спрощеному вигляді процес може виглядати так:
Користувач виконує термінальну дію.
React реєструє оновлення стану.
Якщо одночасно триває transition, React призупиняє менш пріоритетну роботу.
React виконує render-фазу для термінального оновлення.
Готовий результат комітується в DOM.
React повертається до відкладеної роботи.
Якщо за цей час з’явилися нові дані, застарілу роботу може бути відкинуто.
Після завершення актуальний результат комітується.
Це зменшує час, протягом якого головний потік зайнятий непотрібним або менш важливим рендерингом.
Оновлення керованого input має бути термінальним. Відкладати потрібно важкі результати, а не саме введене значення.
startTransition не робить обчислення швидшими. Він лише змінює їхній пріоритет і дає React можливість перервати їх.
Якщо компонент виконує надто багато роботи, його все одно потрібно оптимізувати:
уникати непотрібних обчислень;
використовувати мемоізацію там, де вона справді допомагає;
не рендерити величезні списки повністю;
розділяти великі компоненти.
Не можна виконувати під час виклику компонента:
зміну глобальних змінних;
запити;
підписки;
запис у сховища;
ручні зміни DOM.
Рендеринг може бути повторений або скасований.
React може обчислити результат, а потім відкинути його через новіше оновлення. Логіку, яка має виконуватися лише для реально застосованого результату, потрібно розміщувати в commit-залежних механізмах, наприклад у useEffect.
isPending як індикатора мережіisPending повідомляє про стан transition-оновлення, а не безпосередньо про стан HTTP-запиту. Для мережевих станів потрібно окремо зберігати статус запиту.
Concurrent Rendering не запускає компоненти одночасно в кількох JavaScript-потоках. React лише керує порядком і моментом виконання роботи в межах головного потоку.
Concurrent Rendering дає React змогу переривати та продовжувати render-фазу.
Commit-фаза застосовує готові зміни атомарно.
React може відкинути застарілий незавершений рендеринг.
startTransition і useTransition призначені для непершочергових оновлень.
useDeferredValue створює відкладену версію значення без фіксованого таймера.
Термінальні взаємодії, зокрема введення в поле, не слід робити transition-оновленнями.
Render-фаза повинна бути чистою, оскільки компонент може викликатися повторно.
Concurrent Rendering не створює додаткових потоків і не прискорює самі обчислення.
Головна мета моделі — зберегти чутливість інтерфейсу, коли React виконує велику кількість роботи.