lenec ru

← все посты

Hydration failed because the initial UI does not match — реальные причины

11K

Эту ошибку видел каждый, кто хоть раз писал на 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 — это, по сути, логика, которая не должна была отличаться, но отличается. Половина кейсов решается за минуту, как только понимаешь, что именно различается. Главное — не тушить лампочку, а смотреть, что именно она светит.

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

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

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