lenec ru

← все посты

Hydration mismatch в Next: 5 типичных причин

10K

Ошибка Hydration failed because the initial UI does not match what was rendered on the server — самое раздражающее в Next.js. На первый взгляд кажется случайной, и каждый раз приходится копаться. На самом деле причин обычно пять, и они повторяются от проекта к проекту. Расскажу, как их распознавать и чинить.

Что такое hydration mismatch

Когда Next.js рендерит страницу на сервере, он отдаёт HTML. Браузер получает его, начинает парсить, и тут React пытается «оживить» этот HTML — присвоить event listeners, восстановить состояние. Это и есть гидрация.

Если HTML на сервере и React на клиенте рисуют разную разметку, React понимает, что что-то не так, и валит ошибку. В режиме разработки видно подробное сообщение, в проде — просто страница перерисовывается полностью, что плохо для UX и метрик.

Причина 1. Date.now() и Math.random()

Самый частый и очевидный случай. Сервер вызывает Date.now(), получает 1700000000000. Через 200 мс клиент гидрируется, вызывает Date.now(), получает 1700000000200. Разные значения — mismatch.

// плохо
function Component() {
  return <p>Загружено в {new Date().toLocaleString()}</p>;
}

Решение — рендерить динамическое значение только на клиенте через useEffect:

'use client';
function Component() {
  const [now, setNow] = useState<string | null>(null);
  useEffect(() => {
    setNow(new Date().toLocaleString());
  }, []);
  return <p>Загружено в {now ?? '...'}</p>;
}

Или использовать suppressHydrationWarning, если разница допустима:

<p suppressHydrationWarning>{new Date().toLocaleString()}</p>

Но это flag «я знаю, что делаю». Не злоупотребляй.

Причина 2. Локализация дат и чисел

Этот случай коварнее. Date.toLocaleString() зависит от локали окружения. На сервере (Node) одна локаль, у пользователя в браузере — другая. Результат: «1.05.2026» на сервере и «5/1/2026» на клиенте.

Та же проблема с Number.toLocaleString() и Intl.NumberFormat без явной локали.

// плохо
const formatted = (1234.5).toLocaleString();

// хорошо
const formatted = (1234.5).toLocaleString('ru-RU');

Всегда указывай локаль явно. И помни: если у тебя локаль в URL (/ru/...) — на сервере она доступна, на клиенте через useRouter. Пробрасывай в компонент пропсом, не вытаскивай асинхронно.

Причина 3. Размеры окна и медиа-запросы

Сервер не знает ширину окна пользователя. Если ты рендеришь по-разному для мобильного и десктопа на основе window.innerWidth или matchMedia — будет mismatch.

// плохо
function Component() {
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
  return isMobile ? <Mobile /> : <Desktop />;
}

Решение зависит от задачи. Если разница только в стилях — используй CSS-медиа-запросы:

.desktop { display: none; }
@media (min-width: 768px) {
  .mobile { display: none; }
  .desktop { display: block; }
}

Если разница в логике — рендери оба варианта на сервере, а на клиенте показывай нужный после mount. Или используй cookie с user-agent (детект на сервере, отдаём правильный HTML).

Причина 4. localStorage и cookies на клиенте

Если компонент читает localStorage для определения темы или языка — на сервере этого нет. На сервере отрисуется один вариант, на клиенте — другой.

// плохо
function ThemeToggle() {
  const isDark = typeof window !== 'undefined' && localStorage.getItem('theme') === 'dark';
  return <div className={isDark ? 'dark' : 'light'}>...</div>;
}

Решения:

  • Хранить тему в cookie, читать на сервере, передавать в компонент.
  • Рендерить «нейтральный» вариант на сервере, обновлять после mount.
  • Использовать inline-скрипт в <head>, который ставит класс на html до первого рендера.

Я предпочитаю комбинацию: cookie + inline-скрипт. Cookie ловит большинство случаев, inline-скрипт страхует на первый визит.

Причина 5. HTML, который браузер «исправляет»

Это самый сложный случай. Если ты пишешь невалидный HTML, браузер его сам нормализует, и React после этого не может сопоставить.

Классические примеры:

<!-- <p> внутри <p> — браузер закрывает первый -->
<p>Текст с <p>вложенным абзацем</p> внутри</p>

<!-- <a> внутри <a> — браузер закрывает первый -->
<a href="/outer">
  Внешняя ссылка с <a href="/inner">вложенной</a>
</a>

<!-- <tr> вне <table> — браузер выкидывает -->
<div>
  <tr>Это не работает</tr>
</div>

<!-- <li> вне <ul>/<ol> -->
<div>
  <li>Без обёртки</li>
</div>

На сервере React генерирует HTML с этими ошибками. Браузер при парсинге их «чинит». В итоге дерево DOM не совпадает с тем, что React ожидает, и гидрация валится.

Решение — писать валидный HTML. Используй ESLint с eslint-plugin-jsx-a11y, он ловит часть таких ошибок. На крайний случай — открой DevTools, посмотри Elements и сравни с тем, что видишь в JSX.

Бонусная причина: разные внешние данные

Если на сервере и клиенте делается отдельный fetch одних и тех же данных, и они приходят разные (например, кеш на сервере и не закешировано на клиенте) — будет mismatch.

В App Router этого редко возникает, потому что данные приходят с серверного компонента. Но если у тебя клиентский useQuery запрашивает то же самое — может расходиться.

Решение — Server Components передают данные пропсами или через HydrationBoundary от TanStack Query. Тогда клиент стартует с теми же данными.

Как искать причину

В режиме разработки React 18+ показывает «дифф» дерева:

Hydration failed because the initial UI does not match what was rendered on the server.
+ <p>1.05.2026</p>
- <p>5/1/2026</p>

Эту разницу полезно искать в коде поиском по форматам. В большинстве случаев виновник находится за пять минут.

Если разница не очевидна — попробуй console.log внутри компонента. Лог покажет, что именно рендерится на сервере (в терминале Next dev) и на клиенте (в DevTools).

Как предотвращать

  1. Любые серверо-зависимые данные передавай пропсами от Server Components.
  2. Динамическую логику с window, document, localStorage запускай только в useEffect.
  3. Локали — всегда явные.
  4. Темы и язык — через cookie + inline-скрипт.
  5. HTML-валидность — ESLint и регулярная проверка.

suppressHydrationWarning: когда оно нормально

Этот атрибут — не «выключатель ошибки», а «я понимаю причину и принимаю последствия». Используй для:

  • Времени, которое заведомо отличается на сервере и клиенте (но визуально неважно).
  • Кастомных значений, которые рендерятся только на клиенте.
  • Сторонних виджетов, которые модифицируют DOM до гидрации.

В остальных случаях — фикси причину, не глуши предупреждение.

Что копать дальше

Hydration mismatch — это всегда симптом несоответствия между серверным и клиентским рендером. Лекарство одно — синхронизировать источники истины. На небольших проектах за неделю можно полностью избавиться от этих ошибок и больше с ними не сталкиваться. Главное — поймать привычку проверять дерево после каждой правки SSR-логики.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.