Next.js, localStorage в App Router без hydration-ошибок
- пятница, 31 июля 2026 г. в 00:00:04
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 разойдутся.
Первый вариант оставляет серверный 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-слоя.
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.