Пошук уроків, статей та іншого контенту
Сервер надсилає готовий HTML, але сторінка стає інтерактивною лише після hydration — розбираємось, що це за процес і чому він іноді ламається.
Hydration — це процес, під час якого React підключає JavaScript-логіку до HTML, який уже був згенерований на сервері.
Під час звичайного клієнтського рендерингу браузер отримує майже порожній HTML, завантажує JavaScript, а React створює інтерфейс у DOM. Користувач часто бачить сторінку лише після завершення цього процесу.
За серверного рендерингу послідовність інша:
Сервер виконує React-компоненти.
Сервер генерує готовий HTML.
Браузер показує цей HTML користувачу.
Завантажується JavaScript.
React виконує hydration і «оживляє» вже наявну розмітку.
Тому користувач може побачити заголовок, текст або кнопки ще до завантаження JavaScript. Але до завершення hydration кнопки можуть не реагувати на натискання.
Рендеринг створює або оновлює інтерфейс у DOM. Hydration має іншу початкову мету: використати вже існуючий DOM, створений сервером, і пов’язати його з React-компонентами.
Наприклад, сервер може надіслати:
<button>Лічильник: 0</button>Після hydration React:
знаходить цей елемент;
зіставляє його з компонентом;
підключає обробник події onClick;
запам’ятовує стан компонента;
готує подальші оновлення через React.
Сам HTML не стає інтерактивним лише тому, що він уже відображений у браузері. Інтерактивність з’являється після завантаження та виконання JavaScript.
У React 18 для hydration використовується hydrateRoot.
import { hydrateRoot } from 'react-dom/client';
import App from './App.jsx';
const rootElement = document.getElementById('root');
hydrateRoot(rootElement, <App />);Сервер у цьому випадку має відрендерити той самий компонент або сумісне з ним початкове дерево:
import { renderToPipeableStream } from 'react-dom/server';
import App from './App.jsx';
const stream = renderToPipeableStream(<App />, {
onShellReady() {
// Сервер надсилає HTML після готовності основної оболонки
}
});У реальному застосунку серверний і клієнтський код зазвичай налаштовує фреймворк. Наприклад, Next.js автоматично виконує серверний рендеринг і hydration для клієнтських компонентів.
Спрощена схема виглядає так:
React-компонент
│
├── сервер: HTML
│
└── браузер: hydration → інтерактивний інтерфейсReact очікує, що початковий результат рендерингу на сервері та клієнті буде однаковим. Якщо сервер створив один текст, а клієнт одразу намагається створити інший, виникає hydration mismatch.
Відображення HTML і готовність JavaScript — це різні етапи.
Уявімо кнопку:
function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? 'Подобається' : 'Подобається'}
</button>
);
}HTML кнопки може бути видимим одразу після відповіді сервера. Проте onClick почне працювати тільки після того, як:
браузер завантажить JavaScript;
React виконає компонент;
завершиться hydration;
обробник події стане доступним.
Проміжок між появою HTML та завершенням hydration називають часом до інтерактивності. Якщо JavaScript великий або мережа повільна, цей проміжок може бути помітним.
Hydration mismatch означає, що серверна розмітка відрізняється від розмітки, яку React очікує отримати під час першого клієнтського рендерингу.
Типові причини:
випадкові значення;
поточний час;
різні часові пояси;
перевірка window або document під час рендерингу;
дані, які змінилися між серверним і клієнтським рендерингом;
різний порядок елементів;
неправильна HTML-структура;
розширення браузера або сторонні скрипти, які змінюють DOM.
Такий компонент може генерувати різний результат на сервері та клієнті:
function ProductCode() {
return <p>Код: {Math.random()}</p>;
}Сервер, наприклад, створить:
Код: 0.123А клієнт під час hydration отримає:
Код: 0.847Початкові дані потрібно генерувати один раз на сервері та передавати клієнту або створювати після hydration.
Поточний час також не є стабільним значенням:
function CurrentTime() {
return <time>{new Date().toLocaleTimeString()}</time>;
}Між виконанням коду на сервері та клієнті може пройти навіть кілька секунд. Крім того, сервер і браузер можуть використовувати різні часові пояси або локалі.
Цей код потенційно створює різну розмітку:
function PlatformMessage() {
return (
<p>
{typeof window === 'undefined' ? 'Сервер' : 'Браузер'}
</p>
);
}На сервері буде текст Сервер, а в браузері — Браузер. Для першого рендерингу вони повинні збігатися.
window, document, localStorage та інші браузерні API недоступні під час серверного рендерингу. Навіть якщо перевірити їх наявність, результат перевірки може спричинити різний HTML.
Клієнтську логіку, яка не потрібна для початкової розмітки, краще запускати в useEffect.
import { useEffect, useState } from 'react';
function ThemeInfo() {
const [theme, setTheme] = useState(null);
useEffect(() => {
// Читаємо налаштування лише в браузері після hydration
setTheme(localStorage.getItem('theme') || 'light');
}, []);
if (theme === null) {
return <p>Завантаження налаштування…</p>;
}
return <p>Поточна тема: {theme}</p>;
}Під час серверного рендерингу та першого клієнтського рендерингу компонент показує однаковий текст. Після hydration useEffect читає localStorage і оновлює стан.
Важливо: useEffect не виконується на сервері. Це робить його зручним місцем для браузерної логіки, але не слід без потреби переносити туди всі дані. Якщо дані потрібні для швидкого відображення сторінки, краще отримати їх на сервері та передати компоненту.
Дані також мають бути узгодженими. Припустімо, сервер отримав список товарів, а клієнт під час першого рендерингу одразу запитав API ще раз. Відповіді можуть відрізнятися:
товар з’явився або зник;
змінилася ціна;
змінився порядок;
користувач отримав інші персоналізовані дані.
У результаті сервер і клієнт створять різну розмітку.
Надійний підхід:
отримати початкові дані на сервері;
використати їх для серверної розмітки;
передати ті самі дані клієнту;
виконувати оновлення вже після hydration.
Для складних застосунків фреймворки та бібліотеки керування даними можуть мати власні механізми передавання серверного стану. Основний принцип залишається тим самим: перший клієнтський результат повинен відповідати серверному HTML.
React не додає окремий обробник до кожного елемента так, як це часто роблять вручну через addEventListener. React використовує власну систему делегування подій.
Для розробника важливіше інше: обробники подій починають повноцінно працювати після hydration. Тому HTML може вже бути видимим, але взаємодія ще недоступна.
Це особливо важливо для:
кнопок;
форм;
меню;
модальних вікон;
перемикачів;
компонентів, що реагують на клавіатуру.
Якщо користувач може натиснути кнопку одразу після появи сторінки, але JavaScript ще не завантажився, потрібно враховувати цей проміжок у дизайні та архітектурі застосунку.
React підтримує потоковий серверний рендеринг. Сервер може надсилати HTML частинами, не чекаючи повного завершення всього дерева компонентів.
Це допомагає швидше показати основну частину сторінки. Проте потокова передача HTML не означає, що вся сторінка миттєво стала інтерактивною.
У великих застосунках різні частини інтерфейсу можуть ставати інтерактивними в різний час. Компоненти, JavaScript яких ще не завантажився, залишаються звичайним HTML до завершення відповідного етапу.
Отже, потрібно розрізняти:
швидкість появи першого HTML;
швидкість завантаження JavaScript;
швидкість завершення hydration;
час, коли конкретна взаємодія стає доступною.
У фреймворках із серверними та клієнтськими компонентами не кожен компонент обов’язково проходить однаковий процес.
Серверний компонент може виконуватися лише на сервері та передавати результат іншим компонентам. Клієнтський компонент потребує JavaScript у браузері й бере участь у hydration або іншому клієнтському оновленні.
Це дозволяє зменшити кількість JavaScript, який надсилається браузеру:
статичний текст можна залишити серверним;
інтерактивний пошук зробити клієнтським;
кнопку перемикання теми реалізувати клієнтським компонентом;
великі інтерактивні частини завантажувати окремо.
Чим менше непотрібного клієнтського JavaScript, тим швидше може завершитися hydration.
React зазвичай повідомляє про hydration mismatch у консолі браузера. Повідомлення може вказувати, що текст або атрибути сервера не збігаються з очікуваними клієнтськими значеннями.
Під час діагностики варто:
знайти компонент, який рендерить змінне значення;
порівняти результат серверного та клієнтського рендерингу;
перевірити використання Math.random(), Date, локалі та часової зони;
знайти звернення до window, document і localStorage;
перевірити дані, отримані з API;
перевірити коректність HTML;
вимкнути розширення браузера, які можуть змінювати сторінку;
перевірити, чи не запускається сторонній скрипт до hydration.
Корисно тимчасово замінювати складні компоненти простим статичним текстом. Так легше визначити, на якому рівні дерева виникає розбіжність.
suppressHydrationWarningReact має атрибут suppressHydrationWarning, який пригнічує попередження про розбіжність на одному рівні:
<time suppressHydrationWarning>
{new Date().toLocaleString()}
</time>Це може бути виправдано для контрольованих випадків, наприклад для тексту, який навмисно відрізняється між сервером і клієнтом.
Але цей атрибут не виправляє проблему:
React не робить значення однаковими;
він не синхронізує стан;
він не робить будь-яку розмітку безпечною;
надмірне використання приховує справжні помилки.
Краще спочатку змінити архітектуру компонента так, щоб перший клієнтський результат відповідав серверному.
Math.random() у JSXВипадкові значення потрібно створювати поза початковим рендерингом або стабільно передавати з сервера.
new Date() у розмітціЧас може відрізнятися через затримку, часовий пояс або локаль. Для динамічного часу можна показати стабільне значення спочатку, а потім оновити його в useEffect.
localStorage під час рендерингуБезпечніше спочатку використати однакове значення за замовчуванням, а налаштування прочитати після hydration.
windowПеревірка середовища може змінити HTML. Браузерну частину слід запускати після hydration або ізолювати в клієнтському компоненті.
Якщо сервер і клієнт отримують різні початкові дані, виникає mismatch. Початковий стан потрібно передати клієнту.
Браузер може автоматично виправити некоректну розмітку ще до того, як React почне hydration. Наприклад, не варто розміщувати блокові елементи в місцях, де HTML-стандарт їх не дозволяє, або вкладати інтерактивні елементи один в один.
suppressHydrationWarningПриглушення попередження не усуває причину. Якщо розбіжність не є навмисною, її потрібно виправити.
createRoot для SSR-розміткиЯкщо контейнер уже містить HTML, створений сервером, для React 18 потрібно використовувати hydrateRoot, а не createRoot. createRoot призначений для клієнтського рендерингу та може не відповідати сценарію серверної розмітки.
Компонент легше узгодити між сервером і клієнтом, якщо його початковий рендеринг:
залежить лише від вхідних props і стабільного стану;
не читає браузерні API;
не використовує випадкові значення;
не залежить від поточного часу;
отримує ті самі дані на сервері та клієнті;
створює коректний HTML;
не змінює DOM сторонніми засобами до hydration.
Практичний шаблон для клієнтського стану:
import { useEffect, useState } from 'react';
function Greeting() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// Позначаємо, що компонент уже працює в браузері
setIsClient(true);
}, []);
return (
<p>
{isClient ? 'Інтерактивна версія' : 'Початкова версія'}
</p>
);
}На сервері та під час першого рендерингу в браузері відображається Початкова версія. Після hydration компонент оновлюється й показує клієнтське повідомлення.
Водночас такий підхід потрібно використовувати усвідомлено: додатковий клієнтський рендеринг має сенс лише тоді, коли дані справді доступні тільки в браузері.
Hydration — це не повторне завантаження сторінки, а підключення React до HTML, який уже створив сервер. Завдяки цьому користувач швидше бачить вміст, а після завантаження JavaScript отримує повноцінну інтерактивність.
Найважливіше правило — серверний HTML і перший клієнтський рендеринг повинні відповідати один одному. Для цього варто:
не використовувати випадковість і поточний час у початковому JSX;
не читати браузерні API під час серверного рендерингу;
узгоджувати початкові дані;
запускати клієнтську логіку в useEffect;
використовувати hydrateRoot для серверної розмітки;
не приховувати справжні проблеми через suppressHydrationWarning;
зменшувати обсяг непотрібного клієнтського JavaScript.
Коли ці правила дотримані, серверний HTML залишається швидким для першого відображення, а React безпечно перетворює його на інтерактивний інтерфейс.