Hydration failed because the initial UI does not match — реальные причины
Эту ошибку видел каждый, кто хоть раз писал на Next.js или другом SSR-фреймворке. В консоли — большая красная плашка про несовпадение разметки на сервере и на клиенте. Чаще всего разработчик идёт в Stack Overflow, пробует пять рандомных «фиксов» и в итоге заворачивает компонент в typeof window !== 'undefined'. Лечит симптом, не причину.
Я через это прошла на проекте, который мы переезжали с CSR на App Router. Покажу, что реально вызывает hydration mismatch и как его правильно лечить.
Что вообще происходит
SSR-фреймворк рендерит твой компонент на сервере и отдаёт HTML. Браузер получает этот HTML, показывает его пользователю, потом загружает JS и пытается «приклеиться» к существующему DOM — это и есть гидрация. React сравнивает свою новую виртуальную разметку с тем, что уже на странице. Если они отличаются — hydration mismatch.
Главное правило: на этапе первого рендера на клиенте JSX должен дать ровно тот же DOM, что и на сервере. Любое расхождение — ошибка.
Топ причин, которые я видела в проде
1. Дата, время, рандом
Самый частый случай. На сервере получается одно значение, на клиенте — другое:
// плохо
export function Footer() {
return <p>© {new Date().getFullYear()} Acme</p>;
}В декабре 23:59 по серверу и в январе 00:01 по клиенту легко получить разные годы. То же самое со Math.random() — он гарантированно отличается.
Решение зависит от задачи:
- если значение должно быть одинаковым — генерировать его на сервере, передавать в пропсы компонента и рендерить как обычный текст;
- если значение клиентское по природе — рендерить его в
useEffectпосле маунта, а до этого показывать плейсхолдер.
'use client';
import { useEffect, useState } from 'react';
export function ClientYear() {
const [year, setYear] = useState<number | null>(null);
useEffect(() => setYear(new Date().getFullYear()), []);
return <span>{year ?? '\u00A0'}</span>;
}2. Локализация чисел и дат
На сервере у Node одна локаль (часто en-US), у браузера — твоя системная. toLocaleString() на сервере и на клиенте дадут разные строки:
// сервер: 1,234.5
// клиент: 1 234,5
<p>{(1234.5).toLocaleString()}</p>Лечится явной локалью:
<p>{(1234.5).toLocaleString('ru-RU')}</p>3. localStorage, window, navigator
Их нет на сервере. Если ты на этапе рендера читаешь тему из localStorage, на сервере получаешь дефолт «light», а на клиенте — реальное «dark». Получаешь mismatch.
Правильный путь — рендерить нейтральный вариант на сервере, а на клиенте после маунта переключать. Тема — отдельная история, есть либо next-themes, либо инлайн-скрипт в <head>, который ставит класс на html до гидрации, чтобы избежать вспышки.
4. Невалидный HTML
Это, наверное, моя любимая ловушка. Браузер сам исправляет невалидную вложенность тегов перед тем, как React начнёт гидрировать.
// плохо
<p>
Текст и
<div>вложенный блок</div>
</p>Браузер закроет p перед div, потому что блочный элемент не может лежать внутри параграфа. После этого DOM не совпадает с тем, что рендерил React, и hydrate mismatch гарантирован.
Аналогичная история — <a> внутри <a>, <table> без <tbody> в неподходящем месте, <button> внутри <button>.
Лечится только структурой JSX. Если в консоли видишь warning про nested-теги — это сразу подсветит виновника.
5. Расширения браузера
Coca-Cola Grammarly и подобные инжектят свои атрибуты прямо в <body> или в инпуты. У тебя в JSX этих атрибутов нет, в гидрации — есть. React ругается.
В Next 13+ это часто проявляется как data-new-gr-c-s-check-loaded и подобные. Реальный виновник — Grammarly у пользователя. Чинится либо suppressHydrationWarning на корневых элементах, либо игнорированием — в твоём коде проблемы нет.
<body suppressHydrationWarning>
{children}
</body>Использовать suppressHydrationWarning точечно — нормально. Расставлять по всему дереву — скрывать настоящие баги.
6. Условный рендер по флагу клиента
Класический антипаттерн:
const [isClient, setIsClient] = useState(false);
useEffect(() => setIsClient(true), []);
return isClient ? <ClientOnlyThing /> : null;Это работает, но это не «фикс mismatch» — это просто скрывает компонент на этапе SSR. Если тебе действительно нужна клиентская часть и серверная разметка, разделяй на два компонента: серверный для статики и клиентский (с 'use client') для динамики.
Как искать причину
Когда ошибка прилетает, я делаю одно: смотрю на вывод React. В современных версиях он показывает кусок DOM, где сервер сказал одно, а клиент другое. Этого хватает, чтобы найти конкретный кусок JSX. Дальше остаётся проверить:
- не зависит ли значение от текущего времени или рандома;
- не лезу ли я в браузерные API на этапе рендера;
- не сломалась ли структура HTML;
- может ли быть расширение браузера, которое инжектит атрибуты.
Что я НЕ делаю
- Не оборачиваю всё подряд в
dynamic(() => ..., { ssr: false }). Это потеря SEO и больно для производительности. - Не суну
typeof window !== 'undefined'прямо в JSX «чтобы заработало». Это создаёт мини-CSR-приложение внутри SSR-страницы. - Не игнорирую warning. Если он есть — он рано или поздно станет настоящей ошибкой.
Hydration mismatch — это, по сути, логика, которая не должна была отличаться, но отличается. Половина кейсов решается за минуту, как только понимаешь, что именно различается. Главное — не тушить лампочку, а смотреть, что именно она светит.