localStorage существует только в браузере. App Router может подготовить HTML на сервере и для Client Component. Код с директивой use client получает состояние, эффекты и обработчики событий, но первый рендер всё ещё может пройти без window и localStorage. И возникают два сбоя. Прямое чтение localStorage падает на сервере. Проверка через typeof window возвращает разные данные на сервере и в браузере. Сервер строит пустое избранное, браузер видит сохранённые id, React получает разные деревья при 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-состояние, для списка показать блок загрузки.
Второй вариант отключает 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-слоя.
Событие storagestorage синхронизирует документы одного 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.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как я организовал проект на Next.js | 0 | 6.5 | 21-07-2026 |
| 2 | React Update Lifecycle: что происходит при обновлении компонента | 0 | 8.86 | 05-08-2026 |
| 3 | Как я написал собственный Event Bus на JavaScript | 0 | 6.83 | 31-07-2026 |
| 4 | Микрофронтенды. Стабильная интеграция нескольких SPA-приложений. Часть 1 | 0 | 5 | 29-06-2026 |
| 5 | Микрофронтенды. Стабильная интеграция нескольких SPA-приложений. Часть 2 | 0 | 5 | 30-06-2026 |
| 6 | Offline‑first PWA на примере фитнес‑трекера | 0 | 8.55 | 05-08-2026 |
| 7 | AAA Движок для GTA San Andreas в браузере и переписывание ThreeJS | 0 | 6.18 | 04-08-2026 |
| 8 | Картинка грузится, а fetch к тому же хосту — нет: как CSP тихо сломала три интеграции | 0 | 7.2 | 04-08-2026 |
| 9 | Сложные сайты на вайбкоде: четыре лендинга, два пути к «дорогим» эффектам и 3D-логотип из спрайтов | 0 | 9.77 | 05-08-2026 |
| 10 | Оптимизация без AI: как я автоматизировал API-ручки и типы | -2 | 5 | 29-06-2026 |