Skip to main content

Гідратація React та рендеринг на стороні сервера (SSR)

React гідратація (hydration) - процес, коли React прикріплює обробники подій і стан до HTML, згенерованого на сервері, без перебудови DOM. Браузер використовує готову розмітку від сервера, а не будує все з нуля.

Теорія

TL;DR

  • SSR - це як ресторан, де страву подають уже готовою. Гідратація - це коли офіціант каже "можна їсти". CSR - це коли готуєш прямо за столиком.
  • SSR надсилає готовий HTML одразу (швидкий перший рендер), потім React його "пробуджує". CSR надсилає порожню сторінку і React будує DOM у браузері.
  • Гідратація падає, якщо HTML сервера не збігається з тим, що React очікує на клієнті. Тоді React робить повний повторний рендеринг.
  • SSR для публічних сайтів, де важливі SEO і швидкість першого відображення. CSR для внутрішніх інструментів, де це не має значення.

Швидкий приклад

tsx
// Сервер (Node.js + React) import { renderToString } from 'react-dom/server'; const html = renderToString(<App />); // Результат: <div><button>Count: 0</button></div> // Обробників подій ще немає. // Браузер отримує і одразу відображає HTML. // Потім React гідратує: import { hydrateRoot } from 'react-dom/client'; hydrateRoot(document.getElementById('root'), <App />); // React обходить дерево, знаходить існуючі DOM-вузли, прикріплює onClick. // Новий DOM не створюється - використовується серверний HTML.

React обходить дерево компонентів двічі: на сервері для генерації HTML, на клієнті для зіставлення і прикріплення обробників. Перебудови DOM не відбувається.

Ключова різниця: SSR і CSR

SSR надсилає готовий HTML рядок. Браузер відображає його одразу, поки React завантажується у фоні. Гідратація потім "пробуджує" статичну розмітку, прикріплюючи обробники подій і стан. CSR надсилає порожню HTML-оболонку і React будує весь DOM у браузері. Простіше в налаштуванні, але повільніше до першого відображення.

Компроміс конкретний: SSR витрачає CPU сервера на кожен запит. CSR витрачає час користувача при кожному першому відвідуванні.

Коли що використовувати

  • SSR + гідратація: публічні сайти, e-commerce, блоги, контент де важливий SEO, користувачі з повільним інтернетом
  • Тільки CSR: внутрішні дашборди, адмін-панелі, real-time інструменти де SEO не потрібен
  • Гібридний підхід (Islands): великі сайти з переважно статичним контентом і кількома інтерактивними секціями. Astro реалізує це нативно: SSR статичних частин, гідратація тільки інтерактивних компонентів.

Порівняння підходів рендерингу

ПідхідКоли генерується HTMLFirst Contentful PaintSEO
CSRУ браузеріПовільнийПоганий
SSRНа сервері при кожному запитіШвидкийВідмінний
SSGПід час збіркиНайшвидшийВідмінний
ISRЗбірка + ревалідаціяШвидкий + свіжийВідмінний
IslandsСервер (статика) + клієнт (інтерактив)ШвидкийВідмінний

Як гідратація працює всередині

renderToString(<App />) на сервері обходить дерево компонентів і генерує HTML рядок без обробників подій і стану. Браузер отримує це і відображає одразу.

Коли запускається hydrateRoot(), React знову обходить те саме дерево. Але замість створення DOM-вузлів він порівнює очікуваний вивід компонентів з існуючими елементами і прикріплює обробники, ініціалізує стан, налаштовує підписки. Якщо обидва дерева збігаються, гідратація завершується без попереджень. Якщо ні, React виявляє невідповідність (hydration mismatch), логує попередження в development і повертається до повного повторного рендерингу.

React 18 додав два покращення. Вибіркова гідратація (selective hydration) дозволяє різним частинам сторінки гідратуватись незалежно через Suspense межі. Якщо користувач натискає на секцію, яка ще не гідратувалась, React пріоритизує її. Streaming SSR надсилає HTML частинами у міру готовності даних, тому браузер починає рендерити ще до отримання повної відповіді.

Вибіркова гідратація (React 18)

tsx
import { Suspense } from 'react'; function Page() { return ( <div> <Header /> <Suspense fallback={<Spinner />}> <HeavySidebar /> {/* Гідратується незалежно */} </Suspense> <Suspense fallback={<Skeleton />}> <Comments /> {/* Може гідратуватись раніше HeavySidebar */} </Suspense> </div> ); } // Якщо користувач натисне Comments до завершення гідратації, React пріоритизує її.

Типові помилки

Динамічні значення у рендері

tsx
// Неправильно - сервер і клієнт генерують різний час export default function Clock() { const [time, setTime] = useState(new Date().toLocaleString()); return <div>{time}</div>; } // Правильно - встановлюємо час тільки після монтування export default function Clock() { const [time, setTime] = useState(''); useEffect(() => { setTime(new Date().toLocaleString()); }, []); return <div>{time || 'Завантаження...'}</div>; }

Сервер рендерить один timestamp. Браузер генерує трохи інший. Невідповідність. React повторно рендерить з нуля.

Browser API у рендері

tsx
// Неправильно - window не існує на сервері, одразу крашиться export default function WindowSize() { const [width, setWidth] = useState(window.innerWidth); return <div>Width: {width}</div>; } // Правильно export default function WindowSize() { const [width, setWidth] = useState(0); useEffect(() => { setWidth(window.innerWidth); }, []); return <div>Width: {width || 'Завантаження...'}</div>; }

Дані користувача на клієнті замість сервера

tsx
// Неправильно - сервер рендерить "Guest", клієнт рендерить "Alice" після useEffect export default function UserGreeting() { const [user, setUser] = useState(null); useEffect(() => { setUser(getCurrentUser()); }, []); return <div>Привіт {user?.name || 'Guest'}</div>; } // Правильно - отримуємо на сервері, передаємо як пропс export async function getServerSideProps() { const user = await getCurrentUser(); return { props: { user } }; } export default function UserGreeting({ user }) { return <div>Привіт {user.name}</div>; }

Припущення, що гідратація миттєва

tsx
// Користувач може натиснути кнопку до завершення гідратації. // Натискання відбудеться. Нічого не станеться. Жодної помилки у консолі. // Я бачив, як це знижувало конверсію на e-commerce сайтах з великими JS бандлами. export default function Form() { const [hydrated, setHydrated] = useState(false); useEffect(() => { setHydrated(true); }, []); return ( <form onSubmit={handleSubmit}> <button type="submit" disabled={!hydrated}>Відправити</button> </form> ); }

Ненавмисне використання Math.random() у рендері

tsx
// Неправильно - різне значення на сервері і клієнті export default function App() { return <div>{Math.random()}</div>; // Сервер: 0.123, Клієнт: 0.456 - невідповідність } // Прийнятно, якщо навмисно - використовуй suppressHydrationWarning тільки коли розумієш чому export default function App() { return <div suppressHydrationWarning>{Math.random()}</div>; }

Де використовується

  • Next.js: getServerSideProps запускає SSR; hydrateRoot виконується автоматично в браузері
  • Remix: всі маршрути SSR за замовчуванням, гідратація автоматична
  • Astro: SSR статичного HTML, гідратація тільки компонентів з client:load
  • Gatsby: пре-рендеринг під час збірки (SSG), гідратація в браузері
  • Express + React: ручний SSR через renderToString(), ручна гідратація через hydrateRoot()
  • SvelteKit: SSR за замовчуванням, гідратація автоматична

Питання на співбесіді

Q: Яка різниця між гідратацією і повторним рендерингом?
A: Гідратація прикріплює обробники подій до існуючих DOM-вузлів без зміни структури. Повторний рендеринг створює нові DOM-вузли. Гідратація швидка, бо DOM вже є; повторний рендеринг будує все з нуля.

Q: Що відбувається, якщо HTML сервера не збігається з тим, що очікує React?
A: React виявляє невідповідність і повертається до повного повторного рендерингу в браузері. Це нівелює переваги SSR. У development режимі побачите попередження в консолі. Типові причини: Date.now() або Math.random() у рендері, browser-only API, різні дані на сервері і клієнті.

Q: Чи можна гідратувати тільки частину сторінки?
A: Так. React 18 це називає вибіркова гідратація (selective hydration). Загорніть секції в Suspense і React гідратує їх незалежно. Astro йде далі з islands архітектурою: гідратуються тільки компоненти з client:load. React Server Components вирішують це інакше: такі компоненти взагалі не гідратуються, бо не надсилають JavaScript у браузер.

Q: Як виміряти, чи SSR реально допомагає користувачам?
A: Порівняйте First Contentful Paint (FCP) і Time to Interactive (TTI). SSR покращує FCP, бо користувачі бачать HTML одразу. TTI може не змінитись, бо React все одно потрібно завантажити і гідратувати. Вимірюйте через Web Vitals або real user monitoring інструменти.

Q (Senior): Як реалізувати вибіркову гідратацію для сторінки, де інтерактивні тільки 10% компонентів?
A: Використайте islands архітектуру. Рендерте статичні компоненти в HTML на сервері. Надсилайте JavaScript тільки для інтерактивних. Astro це реалізує нативно. В Next.js можна використати React Server Components, щоб тримати неінтерактивні компоненти поза клієнтським бандлом взагалі. Мета - зменшити JavaScript, що надсилається в браузер, а не просто відкласти його виконання. Менший бандл означає швидшу гідратацію тих 10%, що її реально потребують.

Приклади

Базовий: renderToString і hydrateRoot

tsx
// server.tsx import { renderToString } from 'react-dom/server'; import App from './App'; const html = renderToString(<App />); // Повертає: <div data-reactroot=""><h1>Hello</h1><button>Click me</button></div> // Без обробників подій. Без стану. Просто HTML. // client.tsx import { hydrateRoot } from 'react-dom/client'; import App from './App'; hydrateRoot(document.getElementById('root'), <App />); // React зіставляє компоненти з існуючими DOM-елементами. // Прикріплює обробники. Кнопка тепер інтерактивна.

Браузер відображає HTML одразу при отриманні. hydrateRoot потім підключає інтерактивність, не чіпаючи структуру DOM. Перебудови не відбувається.

Середній рівень: сторінка продукту в Next.js

tsx
// pages/products/[id].tsx export async function getServerSideProps({ params }) { const product = await fetchProduct(params.id); return { props: { product } }; } export default function ProductPage({ product }) { const [quantity, setQuantity] = useState(1); return ( <div> <h1>{product.name}</h1> <p>${product.price}</p> <button onClick={() => setQuantity(q => q + 1)}> Додати в кошик ({quantity}) </button> </div> ); } // Сервер рендерить: <button>Додати в кошик (1)</button> // Браузер отримує і відображає HTML одразу. // React гідратує: прикріплює onClick. // Користувач натискає: quantity оновлюється.

Назва і ціна продукту видні до завантаження React. Кнопка стає інтерактивною після гідратації. Все з одного компонента.

Просунутий: Streaming SSR з Suspense межами

tsx
// Кожна Suspense межа стрімиться незалежно. // React надсилає HTML частинами у міру готовності даних. import { Suspense } from 'react'; export default function ProductPage({ productId }) { return ( <div> <ProductHeader productId={productId} /> <Suspense fallback={<p>Завантаження відгуків...</p>}> <Reviews productId={productId} /> {/* Стрімиться, коли повертається запит до БД */} </Suspense> <Suspense fallback={<p>Завантаження рекомендацій...</p>}> <Recommendations productId={productId} /> {/* Стрімиться незалежно - повільний запит не блокує швидкий */} </Suspense> </div> ); } // ProductHeader приходить першим - користувач бачить його одразу. // Reviews і Recommendations стрімляться і гідратуються окремо. // Повільний запит рекомендацій не блокує решту сторінки.

Цей патерн означає, що повільний запит для одного блоку не затримує взаємодію з іншим. Кожна Suspense межа повністю незалежна.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?