Next.js, localStorage в App Router без hydration-ошибок

—

от автора

localStorage существует только в браузере. App Router может подготовить HTML на сервере и для Client Component. Код с директивой use client получает состояние, эффекты и обработчики событий, но первый рендер всё ещё может пройти без window и localStorage. И возникают два сбоя. Прямое чтение localStorage падает на сервере. Проверка через typeof window возвращает разные данные на сервере и в браузере. Сервер строит пустое избранное, браузер видит сохранённые id, React получает разные деревья при hydration.

Первый рендер

Hydration начинается с HTML, который пришёл с сервера. React строит первое клиентское дерево и привязывает обработчики. Разметка сервера и первый результат в браузере должны совпасть. Компонент ломается во время серверного рендера.

"use client";import { useState } from "react";export default function FavoriteButton({ id }) {  const [ids, setIds] = useState(() => {    const raw = localStorage.getItem("favorites");    return raw ? JSON.parse(raw) : [];  });  return (    <button type="button">      {ids.includes(id) ? "В избранном" : "В избранное"}    </button>  );}

localStorage читается в инициализаторе состояния. Сервер не знает такого глобального объекта. Проверка typeof window !== "undefined" убирает ReferenceError, но не гарантирует одинаковую разметку.

const [ids, setIds] = useState(() => {  if (typeof window === "undefined") return [];  const raw = localStorage.getItem("favorites");  return raw ? JSON.parse(raw) : [];});

Сервер вернёт пустой массив. Браузер на первом рендере может вернуть сохранённые значения. Текст кнопки или число в badge разойдутся.

Чтение после mount

Первый вариант оставляет серверный HTML и читает хранилище после mount. Начальное состояние одинаковое на сервере и в браузере.

"use client";import { useEffect, useState } from "react";export default function FavoriteButton({ id }) {  const [isReady, setIsReady] = useState(false);  const [ids, setIds] = useState([]);  useEffect(() => {    const raw = localStorage.getItem("favorites");    const parsed = raw ? JSON.parse(raw) : [];    setIds(Array.isArray(parsed) ? parsed : []);    setIsReady(true);  }, []);  if (!isReady) {    return <button disabled>...</button>;  }  return (    <button type="button">      {ids.includes(id) ? "В избранном" : "В избранное"}    </button>  );}

Сервер и первый клиентский рендер показывают disabled-кнопку. После эффекта компонент читает хранилище и меняет текст. Такой вариант оставляет SSR для поддерева. Между первым HTML и чтением хранилища виден placeholder. Для badge можно вернуть null, для кнопки оставить disabled-состояние, для списка показать блок загрузки.

Client-only поддерево

Второй вариант отключает SSR для компонента, который зависит от localStorage. В проекте Goods Finder так загружаются кнопка избранного, список избранного, история и счётчики в навигации. ssr: false находится в Client Component. В актуальном App Router такая опция работает только там.

// src/components/goods/FavoriteButtonLoader.js"use client";import dynamic from "next/dynamic";const FavoriteButton = dynamic(() => import("./FavoriteButton"), {  ssr: false,  loading: () => (    <button type="button" disabled>      ...    </button>  ),});export default function FavoriteButtonLoader(props) {  return <FavoriteButton {...props} />;}

Внутренний компонент больше не рендерится на сервере. Lazy initializer читает localStorage уже в браузере.

// src/components/goods/FavoriteButton.js"use client";import { useState } from "react";import {  isFavoriteId,  toggleFavoriteId,} from "@/app/_storage/goodsStorage";export default function FavoriteButton({ id }) {  const [isFav, setIsFav] = useState(() => isFavoriteId(id));  function handleClick() {    const nextIsFav = toggleFavoriteId(id);    setIsFav(nextIsFav);  }  return (    <button type="button" onClick={handleClick}>      {isFav ? "В избранном" : "В избранное"}    </button>  );}

Компонент начинает работу с фактическим значением из хранилища. Loader показывает loading-состояние до загрузки внутреннего компонента.

Слой хранения

Вызовы getItem, setItem, JSON.parse и нормализация собраны в goodsStorage.js, не повторяясь в каждой кнопке:

// src/app/_storage/goodsStorage.jsexport const FAVORITES_IDS_KEY = "goods-finder:favorites";export const HISTORY_KEY = "goods-finder:history";export const GOODS_STORAGE_EVENT = "goods-finder:storage";export const HISTORY_LIMIT = 20;function normalizePositiveInt(value) {  const n = Number(value);  if (!Number.isInteger(n)) return null;  if (n <= 0) return null;  return n;}export function readFavoriteIds() {  try {    const raw = localStorage.getItem(FAVORITES_IDS_KEY);    if (!raw) return [];    const parsed = JSON.parse(raw);    if (!Array.isArray(parsed)) return [];    return parsed      .map(normalizePositiveInt)      .filter(value => value !== null);  } catch {    return [];  }}export function writeFavoriteIds(ids) {  try {    const safe = Array.from(      new Set(        ids          .map(normalizePositiveInt)          .filter(value => value !== null)      )    );    localStorage.setItem(FAVORITES_IDS_KEY, JSON.stringify(safe));  } catch {    // Storage недоступен или данные не записались.  }}

Чтение возвращает массив положительных id. Запись удаляет дубли. Ошибка JSON и недоступное хранилище не роняют UI.

В хранилище нельзя считать данные достоверными. Пользователь может изменить значение в DevTools. Старый код мог записать другой формат. Расширение браузера тоже может оставить другие данные. Проверка массива и каждого id остаётся внутри storage-слоя.

Событие storage

storage синхронизирует документы одного origin. Событие приходит в другие вкладки и окна. Вкладка, которая вызвала localStorage.setItem, собственного события storage не получает. Из-за этого один listener на storage не обновит badge в той же вкладке. Кнопка запишет новый id, а счётчик в навигации останется прежним до другого действия. Проект отправляет собственное событие после каждой записи.

export const GOODS_STORAGE_EVENT = "goods-finder:storage";function emitGoodsStorageEvent() {  if (typeof window === "undefined") return;  window.dispatchEvent(new Event(GOODS_STORAGE_EVENT));}export function writeFavoriteIds(ids) {  try {    const safe = Array.from(new Set(ids));    localStorage.setItem(FAVORITES_IDS_KEY, JSON.stringify(safe));    emitGoodsStorageEvent();  } catch {    // ignore  }}

Собственное событие работает в текущей вкладке. Стандартное storage остаётся для других вкладок. Компоненты подписываются на оба события.

"use client";import { useEffect, useState } from "react";import {  GOODS_STORAGE_EVENT,  readFavoriteIds,} from "@/app/_storage/goodsStorage";export default function FavoritesBadge() {  const [count, setCount] = useState(() => readFavoriteIds().length);  useEffect(() => {    const update = () => setCount(readFavoriteIds().length);    window.addEventListener(GOODS_STORAGE_EVENT, update);    window.addEventListener("storage", update);    return () => {      window.removeEventListener(GOODS_STORAGE_EVENT, update);      window.removeEventListener("storage", update);    };  }, []);  if (!count) return null;  return <span>{count}</span>;}

Один обработчик перечитывает хранилище после обоих событий.

Избранное

Избранное хранит только массив id. Название, цена и изображение остаются на серверной стороне.

[7, 31, 4]

Страница избранного читает id из localStorage, затем загружает карточки через внутренний API.

async function loadFavoriteItems(ids) {  const results = await Promise.all(    ids.map(async id => {      const res = await fetch(`/api/goods/${id}`, {        cache: "no-store",      });      const data = await res.json().catch(() => null);      if (!res.ok || !data?.ok) {        return { ok: false, id };      }      return { ok: true, item: data.item };    })  );  return results    .filter(result => result.ok)    .map(result => result.item);}

localStorage хранит выбор пользователя. API возвращает текущее состояние товаров.

В списке есть два состояния. Первый массив содержит id из браузера. Второй массив содержит загруженные карточки.

const [ids, setIds] = useState(() => readFavoriteIds());const [items, setItems] = useState([]);useEffect(() => {  let cancelled = false;  async function run() {    if (ids.length === 0) {      setItems([]);      return;    }    const loaded = await loadFavoriteItems(ids);    if (!cancelled) setItems(loaded);  }  run();  return () => {    cancelled = true;  };}, [ids]);

Флаг cancelled не записывает ответ в компонент после cleanup эффекта.

История просмотров

История хранит id, заголовок и время просмотра. Запись с тем же id удаляется, затем новая запись ставится в начало. Длина массива ограничена двадцатью элементами.

export function addToHistory(id, title) {  const safeId = normalizePositiveInt(id);  if (!safeId) return;  const safeTitle = normalizeTitle(title);  const now = new Date().toISOString();  const current = readHistoryRaw();  const filtered = current.filter(item => {    if (!item || typeof item !== "object") return true;    return Number(item.id) !== safeId;  });  const next = [    { id: safeId, title: safeTitle, at: now },    ...filtered,  ].slice(0, HISTORY_LIMIT);  writeHistoryRaw(next);}

Открытие карточки записывается из отдельного Client Component без UI.

"use client";import { useEffect } from "react";import { addToHistory } from "@/app/_storage/goodsStorage";export default function HistoryPing({ id, title }) {  useEffect(() => {    addToHistory(id, title);  }, [id, title]);  return null;}

Эффект срабатывает в браузере после mount карточки.

Разделение компонентов

Страница товара может остаться серверной. Данные товара загружаются на сервере. Кнопка избранного и запись истории подключаются отдельными клиентскими островками.

export default async function GoodsDetailsPage({ params }) {  const { id } = await params;  const item = await getProductById(id);  return (    <article>      <HistoryPing id={item.id} title={item.title} />      <h1>{item.title}</h1>      <FavoriteButtonLoader id={item.id} />    </article>  );}

Сервер отвечает за данные страницы. Browser-only код остаётся в HistoryPing и FavoriteButtonLoader.

Проверка

Проверка начинается с чистого хранилища. Открываем карточку товара, добавляем id в избранное, переходим на страницу избранного. После reload запись остаётся. После удаления id кнопка и badge меняются в текущей вкладке.

Вторая вкладка открывается на том же origin. Изменение избранного в первой вкладке должно обновить счётчик во второй через storage.

Повреждённое значение проверяется через DevTools.

localStorage.setItem("goods-finder:favorites", "broken-json");

Страница возвращает пустой список и не падает.

Проверяем production-сборку отдельно.

npm run buildnpm start

Компоненты с ssr: false остаются внутри Client Component loaders. В консоли нет hydration-ошибок. Первый серверный HTML не зависит от значения localStorage.

​

ссылка на оригинал статьи https://habr.com/ru/articles/1064926/