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.

Комментарии (2)


  1. lykianovsky
    30.07.2026 13:00

    Подход рабочий, но у него есть цена, о которой стоит сказать прямо: ssr: false — это почти гарантированный layout shift в текущем варианте

    Пока грузится JS, на странице висит заглушка (или пустота), потом компонент монтируется и рисует реальный контент. Совпадут ли они по размеру — вопрос везения. Кнопка «избранное» со счётчиком в шапке: заглушка без числа уже, чем кнопка с «12» — после гидрации шапка дёргается.

    Альтернатива, при которой заглушки не нужны вообще — хранить такое состояние в куках, а не в localStorage. Куки, в отличие от localStorage, доступны серверу: читаем через cookies() из next/headers (или req.headers.cookie в Pages Router) и сразу рендерим правильный HTML. Hydration mismatch не «обходится» отключением SSR, а просто исчезает — сервер и клиент видят одни и те же данные.

    Ограничение одно: куки уходят с каждым запросом и лимит ~4KB, так что это для компактных вещей — id избранного, тема, настройки. Тяжёлое можно оставить в localStorage, а в куках держать только то, от чего зависит первый рендер.

    Короче: ssr: false — норм, когда состояние реально не нужно серверу и мигание некритично.

    Если от него зависит видимая вёрстка — куки и SSR лучше.

    Я просто делал PersistStorage для Next.js приложения, и остановился как раз таки на Cookie, потому что он доступен серверу, и просто гидрацию провожу спокойно и на сервере, и клиенте.

    Единственное что ещё добавить нужно, это правильно фильтровать куки, что бы не отправлять в __HYDRATION_DATA__ или как-то так называется в доме элемент который прокидывает на клиент данные, в котором будут куки.

    Я это реализовал каким образом, я для всего что должно персисится вначале добавляю к ключу название "persist_store", на выходе ключ получается такой - "persist_store_user_v1.0.0" условно, а какая-то чувствительная инфа выглядит как "store_sensitive_v1.0.0", по итогу он возьмёт только с припиской persist_store, проведёт гидрацию, и всё будет работать корректно


    1. lemon_m Автор
      30.07.2026 13:00

      Да, cookies решают другой сценарий. Сервер читает значение до рендера и отдаёт HTML уже с нужным состоянием.

      В проекте localStorage выбран сознательно. Избранное и история живут только в браузере, сервер их не использует. Компоненты с browser-only состоянием загружаются отдельно через ssr: false.

      На текущей реализации проекта например в /goods/5 визуального layout shift при добавлении и удалении из избранного не заметил. Ошибок hydration тоже нет. Размер и поведение элементов заданы самим UI, а не случайностью. В статье есть ссылка на проект, можно проверить, что все ок.

      Если persisted state влияет на первый серверный HTML, cookies подходят лучше. Если состояние нужно только после загрузки клиента, localStorage остаётся нормальным вариантом.

      PersistStorage и фильтрация cookies уже отдельный паттерн. В этой статье рассматривался localStorage и client boundary в App Router.

      Спасибо за комментарий.