javascript

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

  • пятница, 31 июля 2026 г. в 00:00:04
https://habr.com/ru/articles/1064926/

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.js
export 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 build
npm start

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