У класичних версіях React (16–17) серверний рендеринг страждав від фундаментального архітектурного недоліку, відомого в індустрії як «Зловісна долина SSR» (The Uncanny Valley of SSR). Користувач миттєво отримував згенерований сервером HTML-документ, візуально бачив кнопки, форми введення та навігаційні посилання, однак інтерфейс залишався абсолютно неживим: кліки ігнорувалися, випадні меню не розкривалися, а скролінг сторінки смикався.
Причиною цього була монолітна модель гідратації:
React 19 вирішує цю проблему завдяки архітектурі Selective Hydration (Селективна гідратація), глибоко інтегрованій з межами <Suspense>.
Замість монолітного процесу гідратація розбивається на незалежні кванти за допомогою двох фундаментальних механізмів:
В архітектурі React 19 кожна межа <Suspense> виступає в ролі ізольованого острова гідратації. Сервер розмічає межі Suspense спеціальними коментарями HTML (<!--$?--> для блоків в очікуванні та <!--$--> для готових частин).
[ Серверний HTML завантажено в браузер ]
├── <Header /> --> Чанк JS завантажено: [ Гідратовано (Interactive) ]
├── <Suspense> (Острів Товару) --> Чанк JS завантажено: [ Фонова гідратація... ]
└── <Suspense> (Острів Відгуків) --> Чанк JS ще в мережі: [ Очікує завантаження... ]
+----------------- [ Дія користувача: КЛІК! ] -----------------+
Користувач клікає по кнопці "Додати в кошик" у [ Острові Товару ]!
1. Глобальний перехоплювач подій на document ловить click/pointerdown.
2. Рушій Fiber визначає, що вузол перебуває у стані dehydrated.
3. React ескалує пріоритет [ Острова Товару ] до SyncLane.
4. Поточна фонова робота переривається; Острів Товару гідратується негайно.
5. Механізм Event Replay повторно відтворює перехоплений клік.
6. Стан кошика оновлюється без помітної користувачеві затримки!
+--------------------------------------------------------------+Селективна гідратація забезпечує дві ключові архітектурні переваги:
React.lazy), починає гідратуватися негайно, навіть якщо код для інших частин сторінки все ще в дорозі.Браузер отримує потоковий HTML із коментарями <!--$?--> та монтує початкове статичне DOM-дерево.
Браузер асинхронно скачує JS-файли незалежних компонентів через динамічні імпорти React.lazy.
React оживляє доступні блоки чергою в моменти простою процесора на смузі IdleLane (кванти по 5 мс).
Користувач взаємодіє з негідратованим островом. Кореневий слухач захоплює подію в чергу queuedEvents.
Цільовий острів отримує SyncLane, негайно гідратується, після чого збережена подія відтворюється.
Що відбудеться в React 19, якщо користувач клікне по кнопці всередині <Suspense>, чий JavaScript-код уже завантажився, але фонова гідратація ще не дійшла до цього блоку?
<Suspense> — вся інша сторінка зберігає серверний HTML.Браузер створює DOM-ноди з маркерами <!--$?-->. React реєструє глобальні слухачі подій на document.
У міру завантаження чанків React обходить дерева всередині Suspense у фоновому режимі на IdleLane.
Користувач клікає по негідратованому елементу. Слухач захоплює подію та зберігає в queuedDiscreteEvents.
Цільовий острів отримує найвищий SyncLane. Двигун синхронно завершує гідратацію і запускає replayEvent().
У ядрі react-dom модуль ReactDOMEventReplayer реєструє кореневі слухачі подій (click, mousedown, mouseup, keydown, touchstart, focusin) на етапі первинної ініціалізації контейнера.
Дискретні події відрізняються від неперервних (continuous):
click, keydown, pointerdown) зберігаються в чергу і відтворюються, оскільки представляють усвідомлений намір користувача.mousemove, scroll, pointermove) не буферизуються, щоб не перевантажувати чергу застарілими координатами курсора.interface QueuedDiscreteEvent {
blockedOn: FiberNode | null; // Вузол Fiber, завершення гідратації якого очікує подія
domEventName: string;
event: Event;
targetContainer: Node;
}
const queuedDiscreteEvents: QueuedDiscreteEvent[] = [];
export function dispatchDiscreteEvent(
domEventName: string,
event: Event,
targetContainer: Node
): void {
const target = event.target as Node | null;
const targetFiber = getFiberFromNode(target);
// Якщо вузол ще не гідратовано (перебуває у стані dehydrated)
if (targetFiber !== null && isFiberDehydrated(targetFiber)) {
// 1. Блокуємо стандартну поведінку та спливання
event.preventDefault();
event.stopPropagation();
// 2. Зберігаємо подію в чергу очікування
queuedDiscreteEvents.push({
blockedOn: targetFiber,
domEventName,
event,
targetContainer,
});
// 3. ЕСКАЛАЦІЯ ПРІОРИТЕТУ: форсуємо негайну гідратацію цього піддерева!
// Вузол переноситься з IdleLane у SyncLane
escalateHydrationPriority(targetFiber);
return;
}
// Звичайна синхронна обробка для вже гідратованих вузлів
dispatchEvent(domEventName, event, targetContainer);
}
export function replayDiscreteEventsForFiber(hydratedFiber: FiberNode): void {
for (let i = 0; i < queuedDiscreteEvents.length; i++) {
const queued = queuedDiscreteEvents[i];
if (queued.blockedOn === hydratedFiber) {
// Відтворення збереженої події на вже інтерактивному DOM
queued.targetContainer.dispatchEvent(queued.event);
queuedDiscreteEvents.splice(i, 1);
i--;
}
}
}Нижче наведено приклад картки товару, що демонструє ізоляцію критичних зон інтерактивності від повільних фонових блоків за допомогою <Suspense> та селективної гідратації:
import React, { Suspense, lazy, useState } from 'react';
// Ліниве завантаження важких інтерактивних модулів (окремі JS-чанки)
const DynamicColorCustomizer = lazy(() => import('./DynamicColorCustomizer'));
const HeavyReviewSection = lazy(() => import('./HeavyReviewSection'));
const InteractiveSimilarProducts = lazy(() => import('./InteractiveSimilarProducts'));
interface ProductProps {
id: string;
name: string;
price: number;
initialStock: number;
}
// 1. Критичний інтерактивний компонент: панель купівлі
function BuyActionPanel({ price, initialStock }: { price: number; initialStock: number }) {
const [stock, setStock] = useState(initialStock);
const [isAdded, setIsAdded] = useState(false);
const handlePurchase = () => {
if (stock > 0) {
setStock((s) => s - 1);
setIsAdded(true);
}
};
return (
<div className="p-4 bg-bg-elevated border border-border rounded-lg space-y-3">
<div className="flex justify-between items-center">
<span className="text-2xl font-bold text-accent-cyan">${price}</span>
<span className="text-xs text-text-muted">У наявності: {stock} шт.</span>
</div>
<button
type="button"
onClick={handlePurchase}
disabled={stock === 0}
className="w-full py-3 bg-accent-cyan text-bg-surface font-bold rounded-md hover:opacity-90 disabled:opacity-40 transition-opacity"
>
{isAdded ? 'Додано в кошик!' : 'Купити в 1 клік'}
</button>
</div>
);
}
export function ProductCommerceView({ product }: { product: ProductProps }) {
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<header className="space-y-2">
<h1 className="text-3xl font-extrabold text-text-primary">{product.name}</h1>
<p className="text-sm text-text-muted">Артикул: {product.id}</p>
</header>
{/*
ЗОНА 1: Критичний блок купівлі.
Не огорнутий у lazy, гідратується найпершим!
*/}
<BuyActionPanel price={product.price} initialStock={product.initialStock} />
{/*
ЗОНА 2: Кастомізація кольору.
Ізольована межею Suspense. Якщо користувач клікне по кольорах,
React негайно гідратує цей блок позачергово!
*/}
<Suspense fallback={<div className="h-24 bg-bg-elevated/40 rounded animate-pulse" />}>
<DynamicColorCustomizer productId={product.id} />
</Suspense>
{/*
ЗОНА 3: Важкий блок відгуків.
Гідратуватиметься у фоновому режимі (IdleLane) в останню чергу,
не затримуючи готовність кнопок замовлення.
*/}
<Suspense fallback={<div className="h-64 bg-bg-elevated/40 rounded animate-pulse" />}>
<HeavyReviewSection productId={product.id} />
</Suspense>
{/* ЗОНА 4: Схожі товари */}
<Suspense fallback={<div className="h-48 bg-bg-elevated/40 rounded animate-pulse" />}>
<InteractiveSimilarProducts productId={product.id} />
</Suspense>
</div>
);
}У класичному React при розбіжності клієнтського та серверного HTML двигун скидав серверний DOM усієї сторінки й запускав повільний повторний рендеринг клієнта з самого кореня.
У React 19 завдяки межам <Suspense> збій гідратації локалізується:
[ Сторінка: Root Document ]
├── <Header /> --> Гідратація успішна: DOM збережено
├── <Suspense> (Острів віджета) --> ПОМИЛКА: Hydration Mismatch!
│ React локально скидає ТІЛЬКИ цей острів
│ і перемальовує його на клієнті.
└── <Footer /> --> Гідратація успішна: DOM збереженоРешта розмітки документа зберігає серверний HTML, не викликаючи стрибків інтерфейсу.
Чому React перехоплює та ставить у чергу відтворення саме дискретні події (click, pointerdown, keydown), але не неперервні (mousemove, scroll)?
Для відстеження затримок інтерактивності, безпечної обробки динамічних клієнтських значень (таймзони, розмір екрана) та виключення Hydration Mismatch:
import React, {
useState,
useEffect,
Suspense,
useSyncExternalStore,
type ReactNode,
} from 'react';
interface HydrationBoundaryProps {
children: ReactNode;
fallback: ReactNode;
boundaryName: string;
warnThresholdMs?: number;
}
// Порожня підписка для детермінованого визначення клієнтської фази
const emptySubscribe = () => () => {};
/**
* Хук для безпечного визначення клієнтського контексту
* через useSyncExternalStore (на сервері повертає false, на клієнті true).
*/
export function useIsClient(): boolean {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false
);
}
/**
* HydrationBoundary ізолює клієнтські обчислення від SSR
* та фіксує тривалість переходу в інтерактивний стан.
*/
export function HydrationBoundary({
children,
fallback,
boundaryName,
warnThresholdMs = 50,
}: HydrationBoundaryProps) {
const isClient = useIsClient();
const [hydrationTime, setHydrationTime] = useState<number | null>(null);
useEffect(() => {
const startTime = performance.now();
// Вимірювання часу оживлення
requestAnimationFrame(() => {
const duration = performance.now() - startTime;
setHydrationTime(duration);
if (duration > warnThresholdMs) {
console.warn(
`[Hydration Warning] Межа "${boundaryName}" гідратувалася ${duration.toFixed(
1
)} мс (поріг: ${warnThresholdMs} мс). Рекомендована декомпозиція.`
);
}
});
}, [boundaryName, warnThresholdMs]);
return (
<Suspense fallback={fallback}>
<div
data-hydration-boundary={boundaryName}
data-hydrated={isClient ? 'true' : 'false'}
>
{/* До гідратації відображається стабільний серверний fallback */}
{isClient ? children : fallback}
</div>
</Suspense>
);
}Якщо розробник не огортає незалежні важкі віджети (карти, складні таблиці, редактори) у межі <Suspense>, селективна гідратація деградує назад у монолітну. React змушений об'єднати всі вузли в єдиний моноліт, блокуючи інтерактивність усього екрана.
Виклик функцій Date.now(), new Date().toLocaleTimeString() або Math.random() безпосередньо в тілі рендеру гарантує розбіжність між сервером та клієнтом. Це породжує помилку Hydration failed because the initial UI does not match what was rendered on the server і змушує браузер перебудовувати вузол.
// ❌ ПОГАНО: серверний час не збіжиться із часом клієнта під час гідратації
export function LiveClock() {
return <span>{new Date().toLocaleTimeString()}</span>; // Hydration Mismatch!
}
// ✅ ПРАВИЛЬНО: читання динамічного часу строго після завершення монтування
export function LiveClock() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const timer = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(timer);
}, []);
return <span>{time ?? '--:--:--'}</span>;
}Розміщення suppressHydrationWarning на тегах <html>, <body> або кореневому контейнері вимикає попередження React про розбіжності для всіх дочірніх елементів, маскуючи системні баги. Атрибут припустимий лише на точкових текстових вузлах.
Підключення зовнішніх скриптів (перекладачі сторінок, менеджери паролів), що змінюють DOM-структуру до завершення селективної гідратації React, ламає зв'язки у дереві Fiber з помилкою NotFoundError: Failed to execute 'removeChild' on 'Node'.
Конструкція typeof window !== 'undefined' ? <ClientWidget /> : <ServerSkeleton /> формує різний початковий Virtual DOM на сервері та клієнті, спричиняючи Hydration Mismatch. Використовуйте useEffect або хуки на базі useSyncExternalStore.