Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Type-Safe LocalStorage в React: пишем кастомный хук с Zod-валидацией

Тимофей Ищенко

Коротко: Пошаговое руководство по созданию типобезопасного хука 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) из внешнего источника, аналогичная получению данных по сети.

Для безопасной работы требуются три архитектурных элемента:

  1. Runtime-верификация: проверка формы данных до того, как они попадут в состояние компонентов React.
  2. Fallback-значение: возврат безопасного дефолтного значения при отсутствии ключа, синтаксических ошибках JSON.parse или несовпадении схемы.
  3. 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

Сформулируем требования к хуку:

  1. Принимает строковый ключ, Zod-схему и значение по умолчанию (defaultValue).
  2. Автоматически выводит тип возвращаемого значения из схемы (z.infer<T>).
  3. Предоставляет интерфейс, идентичный стандартному useState: кортеж [value, setValue].
  4. Поддерживает функциональные апдейтеры setValue((prev) => next).
  5. Выполняет синхронное чтение из 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

Для надежного продакшен-кода необходимо учесть три нюанса:

  1. Синхронизация между вкладками браузера через событие window.onstorage.
  2. Предотвращение ошибок гидрации (Hydration Mismatch) при SSR в Next.js или Remix.
  3. Самолечение хранилища (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 — обертка над стандартным API localStorage с автоматической валидацией.
  • 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 обеспечивает:

  1. Полную типобезопасность: соответствие статических типов реальной структуре данных во время выполнения.
  2. Отказоустойчивость: защиту приложения от сбоев и белых экранов при изменении контрактов данных.
  3. Чистую архитектуру: единый источник правды (SSOT) для валидации и статических типов TypeScript без дублирования кода.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также