Коротко: Пошаговое руководство по созданию типобезопасного хука useZodLocalStorage в React. Валидация Zod, поддержка SSR в Next.js и синхронизация вкладок.
Конструкция вида JSON.parse(localStorage.getItem('user')) as User встречается во множестве проектов. Она выглядит лаконично, а компилятор TypeScript услужливо подсказывает типы полей. Однако в продакшене эта строчка кода нередко превращается в скрытую мину замедленного действия: если пользователь вручную изменит значение в DevTools, сторонняя библиотека перетрет ключ или новый релиз приложения изменит контракт данных, приложение упадет с ошибкой TypeError: Cannot read properties of undefined.
localStorage — это внешняя, неконтролируемая среда. Для стабильной работы с ней статической типизации недостаточно: требуется обязательная проверка входящих данных во время выполнения (runtime). Разберем, как спроектировать отказоустойчивый React-хук useZodLocalStorage, объединяющий строгую Zod-валидацию, автоматический вывод типов TypeScript, поддержку SSR (Next.js) и синхронизацию между вкладками браузера.
Анатомия проблемы: иллюзия безопасности TypeScript в LocalStorage
Почему Type Assertion (as Type) ломает продакшен
TypeScript проверяет типы исключительно на этапе компиляции. В скомпилированном JavaScript операторы приведения типов вроде as Type или <Type> полностью удаляются.
Рассмотрим практический пример:
interface UserSettings {
theme: 'light' | 'dark';
notifications: {
email: boolean;
push: boolean;
};
}
// Кажется, что settings строго типизирован:
const settings = JSON.parse(localStorage.getItem('settings') || '{}') as UserSettings;
// Если в хранилище остался старый формат { theme: 'light' } без объекта notifications:
console.log(settings.notifications.email); // Uncaught TypeError: Cannot read properties of undefined
При обновлении версий структуры данных у действующих пользователей в localStorage сохраняется устаревший формат. Попытка прочитать вложенное свойство приведет к падению рендера («белому экрану смерти»).
Runtime vs Compile-time проверки
Web Storage API оперирует исключительно строками. Любое чтение из localStorage — это операция ввода-вывода (I/O) из внешнего источника, аналогичная получению данных по сети.
Для безопасной работы требуются три архитектурных элемента:
- Runtime-верификация: проверка формы данных до того, как они попадут в состояние компонентов React.
- Fallback-значение: возврат безопасного дефолтного значения при отсутствии ключа, синтаксических ошибках
JSON.parseили несовпадении схемы. - Self-healing: автоматическая перезапись или очистка некорректных данных в хранилище.
Zod как слой валидации и единый источник правды (SSOT)
Библиотека Zod устраняет дублирование кода. Вместо раздельного описания интерфейса TypeScript и написания вспомогательного Type Guard вы создаете схему Zod, из которой TypeScript выводит типы автоматически.
Вывод типов с помощью z.infer
import { z } from 'zod';
export const userSettingsSchema = z.object({
theme: z.enum(['light', 'dark', 'system']).default('system'),
fontSize: z.number().min(12).max(24).default(16),
sidebarCollapsed: z.boolean().default(false),
});
// Статический тип генерируется автоматически:
export type UserSettings = z.infer<typeof userSettingsSchema>;
При добавлении нового поля или изменении валидационных правил правки вносятся только в схему — интерфейс TypeScript обновится самостоятельно.
Выбор между .parse() и .safeParse()
Метод schema.parse(data) выбрасывает исключение ZodError, если данные не валидны, вынуждая разработчика оборачивать вызовы в блоки try/catch.
Метод schema.safeParse(data) возвращает безопасный discriminated union:
const result = userSettingsSchema.safeParse(rawData);
if (result.success) {
// result.data строго типизирован как UserSettings
console.log(result.data.theme);
} else {
// result.error содержит структурированный список несоответствий
console.warn('Данные повреждены:', result.error.format());
}
Использование safeParse делает обработку крайних случаев предсказуемой и прозрачной.
Проектирование хука useZodLocalStorage
Сформулируем требования к хуку:
- Принимает строковый ключ, Zod-схему и значение по умолчанию (
defaultValue). - Автоматически выводит тип возвращаемого значения из схемы (
z.infer<T>). - Предоставляет интерфейс, идентичный стандартному
useState: кортеж[value, setValue]. - Поддерживает функциональные апдейтеры
setValue((prev) => next). - Выполняет синхронное чтение из
localStorageбез лишних блокировок основного потока при ререндерах.
Базовая реализация с ленивой инициализацией
Операции чтения из localStorage и парсинга JSON являются синхронными и блокируют выполнение JavaScript. Поэтому чтение необходимо передавать в useState в виде функции (lazy initial state), чтобы оно отрабатывало строго один раз при монтировании компонента:
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
import { z } from 'zod';
export function useZodLocalStorage<T extends z.ZodTypeAny>(
key: string,
schema: T,
defaultValue: z.infer<T>
): [z.infer<T>, Dispatch<SetStateAction<z.infer<T>>>] {
type ValueType = z.infer<T>;
const [storedValue, setStoredValue] = useState<ValueType>(() => {
if (typeof window === 'undefined') {
return defaultValue;
}
try {
const item = window.localStorage.getItem(key);
if (item === null) {
return defaultValue;
}
const parsedJson = JSON.parse(item);
const validationResult = schema.safeParse(parsedJson);
if (validationResult.success) {
return validationResult.data;
}
console.warn(
`[useZodLocalStorage] Данные по ключу "${key}" не соответствуют схеме. Сброс к defaultValue.`,
validationResult.error
);
return defaultValue;
} catch (error) {
console.warn(`[useZodLocalStorage] Ошибка чтения/парсинга ключа "${key}":`, error);
return defaultValue;
}
});
const setValue: Dispatch<SetStateAction<ValueType>> = useCallback(
(valueOrFn) => {
try {
setStoredValue((prev) => {
const nextValue =
typeof valueOrFn === 'function'
? (valueOrFn as (prev: ValueType) => ValueType)(prev)
: valueOrFn;
const validationResult = schema.safeParse(nextValue);
if (!validationResult.success) {
console.error(
`[useZodLocalStorage] Попытка записать невалидные данные в ключ "${key}":`,
validationResult.error
);
return prev;
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(validationResult.data));
}
return validationResult.data;
});
} catch (error) {
console.error(`[useZodLocalStorage] Ошибка записи по ключу "${key}":`, error);
}
},
[key, schema]
);
return [storedValue, setValue];
}
Полная реализация с поддержкой Edge Cases
Для надежного продакшен-кода необходимо учесть три нюанса:
- Синхронизация между вкладками браузера через событие
window.onstorage. - Предотвращение ошибок гидрации (Hydration Mismatch) при SSR в Next.js или Remix.
- Самолечение хранилища (Self-healing) — перезапись некорректных данных дефолтным значением на диске.
Итоговый код хука
import { useState, useCallback, useEffect, Dispatch, SetStateAction } from 'react';
import { z } from 'zod';
interface UseZodLocalStorageOptions {
/** Перезаписывать ли поврежденные данные в хранилище значением по умолчанию */
selfHeal?: boolean;
/** Слушать ли изменения из других вкладок */
syncTabs?: boolean;
}
export function useZodLocalStorage<T extends z.ZodTypeAny>(
key: string,
schema: T,
defaultValue: z.infer<T>,
options: UseZodLocalStorageOptions = {}
): [z.infer<T>, Dispatch<SetStateAction<z.infer<T>>>, { isHydrated: boolean }] {
const { selfHeal = true, syncTabs = true } = options;
type ValueType = z.infer<T>;
const [isHydrated, setIsHydrated] = useState(false);
const readValue = useCallback((): ValueType => {
if (typeof window === 'undefined') {
return defaultValue;
}
try {
const raw = window.localStorage.getItem(key);
if (raw === null) {
return defaultValue;
}
const parsed = JSON.parse(raw);
const result = schema.safeParse(parsed);
if (result.success) {
return result.data;
}
if (selfHeal) {
window.localStorage.setItem(key, JSON.stringify(defaultValue));
}
return defaultValue;
} catch (error) {
if (selfHeal && typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(defaultValue));
}
return defaultValue;
}
}, [key, schema, defaultValue, selfHeal]);
const [storedValue, setStoredValue] = useState<ValueType>(defaultValue);
// Синхронизация после монтирования для предотвращения SSR Hydration Mismatch
useEffect(() => {
setStoredValue(readValue());
setIsHydrated(true);
}, [readValue]);
const setValue: Dispatch<SetStateAction<ValueType>> = useCallback(
(valueOrFn) => {
try {
setStoredValue((prev) => {
const nextValue =
typeof valueOrFn === 'function'
? (valueOrFn as (prev: ValueType) => ValueType)(prev)
: valueOrFn;
const result = schema.safeParse(nextValue);
if (!result.success) {
console.error(
`[useZodLocalStorage] Ошибка валидации при записи в "${key}":`,
result.error.issues
);
return prev;
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(result.data));
window.dispatchEvent(
new StorageEvent('storage', {
key,
newValue: JSON.stringify(result.data),
})
);
}
return result.data;
});
} catch (error) {
console.error(`[useZodLocalStorage] Ошибка сохранения "${key}":`, error);
}
},
[key, schema]
);
useEffect(() => {
if (!syncTabs || typeof window === 'undefined') return;
const handleStorageChange = (event: StorageEvent) => {
if (event.key !== key) return;
if (event.newValue === null) {
setStoredValue(defaultValue);
return;
}
try {
const parsed = JSON.parse(event.newValue);
const result = schema.safeParse(parsed);
if (result.success) {
setStoredValue(result.data);
}
} catch {
setStoredValue(defaultValue);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, schema, defaultValue, syncTabs]);
return [storedValue, setValue, { isHydrated }];
}
Практический пример: хранение пользовательских фильтров
Посмотрим, как использовать хук в компоненте фильтрации каталога:
import React from 'react';
import { z } from 'zod';
import { useZodLocalStorage } from './useZodLocalStorage';
const catalogFiltersSchema = z.object({
category: z.string(),
priceRange: z.tuple([z.number().min(0), z.number().max(100000)]),
inStockOnly: z.boolean(),
tags: z.array(z.string()),
});
type CatalogFilters = z.infer<typeof catalogFiltersSchema>;
const initialFilters: CatalogFilters = {
category: 'all',
priceRange: [0, 50000],
inStockOnly: true,
tags: [],
};
export const CatalogView: React.FC = () => {
const [filters, setFilters, { isHydrated }] = useZodLocalStorage(
'catalog_filters_v1',
catalogFiltersSchema,
initialFilters
);
// Предотвращение мерцания до завершения клиентской гидрации в Next.js
if (!isHydrated) {
return <div>Загрузка параметров...</div>;
}
const toggleInStock = () => {
setFilters((prev) => ({
...prev,
inStockOnly: !prev.inStockOnly,
}));
};
const updatePriceMax = (max: number) => {
setFilters((prev) => ({
...prev,
priceRange: [prev.priceRange[0], max],
}));
};
return (
<aside className="filters-panel">
<h3>Параметры каталога</h3>
<label>
<input
type="checkbox"
checked={filters.inStockOnly}
onChange={toggleInStock}
/>
Только в наличии
</label>
<div>
<span>Максимальная цена: {filters.priceRange[1]} ₽</span>
<input
type="range"
min="1000"
max="100000"
step="1000"
value={filters.priceRange[1]}
onChange={(e) => updatePriceMax(Number(e.target.value))}
/>
</div>
</aside>
);
};
Если данные в localStorage будут повреждены или окажутся в невалидном формате, хук вернет initialFilters и перезапишет ключ корректными данными.
Когда писать свой хук, а когда брать готовые библиотеки?
В экосистеме существуют библиотеки для типизации Web Storage:
zod-localstorage— предоставляет словарь сопоставления ключей со схемами.@stork-tools/zod-local-storage— обертка над стандартным APIlocalStorageс автоматической валидацией.ts-souko— модульное решение для типизированных хранилищ с кодеками.
Сравнительный анализ решений
| Критерий | Кастомный хук (useZodLocalStorage) |
Готовая npm-библиотека |
|---|---|---|
| Реактивность в React | Полная нативная интеграция через хук | Требует ручной обвязки через стейт или внешние подписки |
| Поддержка SSR / Next.js | Встроенный флаг isHydrated |
Зависит от конкретного пакета |
| Размер бандла | ~1 КБ кода к уже установленному Zod | Дополнительный транзитивный пакет |
| Гибкость настроек | Прямой контроль над логированием, fallback и self-healing | Ограничена публичным API библиотеки |
Для большинства React-приложений кастомный хук оказывается выгоднее: он не увеличивает объем внешних зависимостей, прост в аудите безопасности и легко адаптируется под специфику проекта.
Часто задаваемые вопросы (FAQ)
Что происходит, если данные в localStorage не соответствуют Zod-схеме?
Метод schema.safeParse() возвращает объект ошибки. Хук перехватывает ее, возвращает значение по умолчанию (defaultValue) и, если включен флаг selfHeal, перезаписывает поврежденный ключ в хранилище валидным дефолтным значением.
Как предотвратить ошибки гидрации (Hydration Mismatch) в Next.js?
На сервере localStorage отсутствует, поэтому начальный HTML генерируется с defaultValue. На клиенте стейт хука также инициализируется с defaultValue, а актуальные данные из хранилища считываются внутри useEffect после монтирования. Флаг isHydrated позволяет скрывать зависимые элементы интерфейса до завершения клиентской гидрации.
Почему важно использовать функцию инициализации в useState?
Синхронное чтение из localStorage и парсинг JSON блокируют поток выполнения. Передача функции useState(() => readValue()) гарантирует, что доступ к диску произойдет только один раз при монтировании компонента, а не при каждом повторном рендере.
Сильно ли Zod-валидация влияет на производительность рендера?
Парсинг схемы выполняется исключительно в моменты обращения к внешнему хранилищу: при монтировании компонента, входящем событии storage и вызове функции setValue. На регулярные ререндеры React валидация не оказывает влияния.
Можно ли адаптировать хук для sessionStorage?
Да. Логика работы идентична. Достаточно добавить параметр выбора хранилища (storage: Storage = window.localStorage). Единственное отличие: событие storage не синхронизирует состояние между разными вкладками для sessionStorage, так как его контекст изолирован в рамках одной вкладки.
Заключение
Использование утверждения типов as Type при работе с Web Storage создает ложную уверенность в надежности кода. Внешние данные всегда должны валидироваться в рантайме.
Реализация кастомного React-хука со схемами Zod обеспечивает:
- Полную типобезопасность: соответствие статических типов реальной структуре данных во время выполнения.
- Отказоустойчивость: защиту приложения от сбоев и белых экранов при изменении контрактов данных.
- Чистую архитектуру: единый источник правды (SSOT) для валидации и статических типов TypeScript без дублирования кода.




.svg.webp)




