Эта тема отделяет тех, кто «слышал», от тех, кто понимает. Разберём по порядку: что дал React 18, что добавил React 19 и как всё это связано.
Конкурентный рендер
С React 18 и createRoot рендер может быть прерываемым. React готовит новую версию интерфейса в памяти и может бросить её, если пришло более срочное обновление (ввод с клавиатуры). Пользователь не видит промежуточного состояния: DOM меняется только при коммите.
Обновления делятся на:
- срочные: ввод, клик, нажатие. Должны отобразиться сразу;
- несрочные (transitions): перерисовка большого списка, переход между экранами.
Автоматический батчинг
С React 18 несколько setState подряд объединяются в один рендер везде: в обработчиках, промисах, setTimeout. Раньше батчинг работал только в обработчиках React. Выйти из батчинга можно через flushSync из react-dom, но это редкость.
useTransition и startTransition
const [isPending, startTransition] = useTransition();
function onTabClick(tab) {
startTransition(() => {
setTab(tab); // несрочно: можно прервать, если пользователь кликнет снова
});
}
Пока переход идёт, старый интерфейс остаётся интерактивным, isPending равен true. Если внутри перехода что-то приостановилось через Suspense, React не покажет fallback для уже видимого контента, а подержит старый экран.
В React 19 в startTransition можно передать async-функцию: так устроены actions. isPending будет true, пока функция не завершится.
Нельзя оборачивать в переход значение контролируемого поля: поле должно обновляться мгновенно. В переход кладут то, что от поля зависит (фильтрацию списка).
useDeferredValue
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// поле получает query (срочно), тяжёлый список получает deferredQuery
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<SlowList query={deferredQuery} />
Сначала React рендерит со старым deferredQuery, затем в фоне пытается отрендерить с новым. Это не debounce: задержки по времени нет, на быстром устройстве обновление почти мгновенное. Чтобы фоновый рендер был дешёвым, SlowList оборачивают в memo. В React 19 есть второй аргумент initialValue для первого рендера.
useTransition нужен, когда вы владеете вызовом setState. useDeferredValue, когда значение пришло сверху и вы им не управляете.
Suspense
<Suspense fallback={<Spinner />}> показывает запасной UI, пока что-то внутри «приостановлено»: грузится код через lazy или данные через совместимый источник (use, фреймворки, Query-библиотеки с suspense-режимом).
const Settings = lazy(() => import('./Settings'));
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
Сам по себе useEffect с fetch Suspense не запускает.
Хук use (React 19)
use читает значение промиса или контекста. Пока промис не выполнен, компонент приостанавливается, и срабатывает ближайший Suspense; если промис отклонён, ошибка уходит в error boundary.
function Comments({ commentsPromise }) {
const comments = use(commentsPromise);
return comments.map((c) => <p key={c.id}>{c.text}</p>);
}
Особенности:
useможно вызывать в условиях и циклах, в отличие от остальных хуков;- промис нельзя создавать прямо в рендере клиентского компонента: каждый рендер даст новый промис, и компонент будет приостанавливаться снова и снова. Промис создают выше (в серверном компоненте, в загрузчике роутера) или кешируют.
SSR, стриминг, гидрация
Сервер отдаёт HTML, клиент «оживляет» его гидрацией (hydrateRoot). С Suspense сервер стримит HTML частями: оболочка сразу, медленные куски по мере готовности. Гидрация тоже идёт выборочно: React в первую очередь гидрирует ту часть, с которой взаимодействует пользователь.
Ошибка гидрации возникает, когда HTML с сервера не совпал с первым рендером на клиенте: Date.now(), Math.random(), проверка typeof window прямо в рендере. Клиентское значение переносят в эффект.
Серверные компоненты (RSC)
Серверный компонент выполняется только на сервере (или при сборке). Его код не попадает в клиентский бандл. Он может быть async и читать данные напрямую:
// серверный компонент (по умолчанию в Next.js App Router)
export default async function Page() {
const posts = await db.post.findMany();
return <PostList posts={posts} />;
}
Ограничения: нет useState, useEffect, обработчиков событий и браузерных API.
Директива 'use client' в начале файла объявляет границу: этот модуль и всё, что он импортирует, попадает в клиентский бандл. Серверный компонент может рендерить клиентский и передавать ему сериализуемые пропсы (данные, JSX, промисы), но не обычные функции. Клиентский компонент не может импортировать серверный, но может получить его через children.
Директива 'use server' помечает серверные функции, которые клиент может вызвать (например, как action формы). Это не способ объявить серверный компонент: для этого директива не нужна вовсе.
React Compiler
Компилятор (стабильный с осени 2025, подключается плагином к Babel или через фреймворк) на этапе сборки сам расставляет мемоизацию: компонентов, значений, колбэков. Ручные useMemo, useCallback, memo становятся почти не нужны. Условие: код соблюдает правила React (чистый рендер, без мутаций пропсов и состояния). Если компилятор не может доказать безопасность, он пропускает компонент. Отключить для конкретной функции можно директивой 'use no memo'.
Частые ошибки
- Переход для значения поля ввода.
- Ожидание, что
useDeferredValueэто debounce. use(fetch(url))прямо в клиентском компоненте.'use server'в файле серверного компонента.- Передача
onClickиз серверного компонента в клиентский.
Что спросят на собесе
- Что такое конкурентный рендер и чем transition отличается от срочного обновления?
- Чем
useDeferredValueотличается от debounce? - Как работает Suspense и что может его запустить?
- Чем серверные компоненты отличаются от SSR?
- Что делает React Compiler и нужен ли теперь
useMemo?