В одном из своих проектов на React мне нужно было хранить данные между разными заходами пользователя на страницу и при этом использовать их сразу при открытии — не дожидаясь, пока сервер “лениво” вернёт ответ.

Самый простой способ — использовать готовое API браузера: localStorage. Так я и подумал сначала. Но на практике работа с ним приносит больше неудобств, чем кажется. Давайте рассмотрим это на примере:

const [value, setValue] = useState(null);

useEffect(() => {
  const stored = localStorage.getItem("key");
  setValue(JSON.parse(stored));
}, []);

Выглядит достаточно просто, но здесь скрыто несколько проблем:

  • Сначала пользователь видит значение null, а потом уже актуальное значение из localStorage → возникает мигающий UI.

  • В localStorage данные хранятся как строка. При чтении через JSON.parse мы можем получить исключение и "сломать" компонент.

  • Мы не можем нормально типизировать данные из localStorage: они в коде превращаются в any, и мы не можем проверить, что там лежит именно нужный тип.

  • Если у нас есть два компонента, которые читают одно и то же значение из localStorage, то при изменении состояния в одном из них второй об этом не узнает — localStorage сам по себе не уведомляет о локальных изменениях внутри страницы.

  • Сам localStorage живёт вне жизненного цикла React-приложения и может приводить к проблемам в условиях concurrent rendering.

  • Компонент фактически становится client-only из-за использования useEffect и отсутствия localStorage в Node-окружении (SSR).

Шаг 1. Типизированная обёртка над localStorage

Начнём с типизированной обёртки над localStorage, чтобы гарантировать, что мы записываем и ожидаем только корректные данные. (Как гарантировать, что мы читаем корректные данные — обсудим позже)

Сначала добавим типизацию:

export type TypedStorageValue = Record<string, unknown>;

export type TypedStorage = {
  get<K extends Extract<keyof S, string>>(key: K): S[K] | null;
  set<K extends Extract<keyof S, string>>(key: K, value: S[K]): S[K] | null;
  remove(key: Extract<keyof S, string>): void;
  clear(): void;
};

Теперь напишем реализацию. Сразу сделаем её через фабрику — это даст преимущества дальше:

// typedStorage.ts
export function createTypedStorage(): TypedStorage {
  type Keys = Extract<keyof S, string>;
  type Value = S[K];
  const isClient = typeof window !== "undefined";
  
  const getStorage = (): Storage | null => {
      if (!isClient) return null;
  
      return window.localStorage;
  };
  
  return {
      get<K extends Keys>(
          key: K,
      ): Value<K> | null {
          const storage = getStorage();
          if (!storage) return null;
  
          try {
              const raw = storage.getItem(key);
              if (!raw) return null;
  
              return JSON.parse(raw);
          } catch (error) {
              console.warn(`Invalid data for key "${key}":`, error);
              return null;
          }
      },
  
      set<K extends Keys>(key: K, value: Value<K>): Value<K> | null {
          const storage = getStorage();
          if (!storage) return null;
  
          try {
              const raw = JSON.stringify(value);
              storage.setItem(key, raw);
  
              return value;
          } catch (error) {
              console.error(`Failed to save key "${key}":`, error);
              return null;
          }
      },
  
      remove(key: Keys): void {
          getStorage()?.removeItem(key);
      },
  
      clear() {
          getStorage()?.clear();
      },
  };
}

Шаг 2. Хук для React: почему не useState

Но как использовать это в компонентах? Сейчас это не похоже на типичный хук React. Давайте напишем хук, который можно легко переиспользовать.

Как я писал выше, localStorage живёт вне жизненного цикла React и является внешним хранилищем. В таких случаях useState лишь создаёт дополнительные места для рассинхронизации (мы будем хранить копию значения, которая может устареть). Это именно та ситуация, под которую начиная с React 18 добавили useSyncExternalStore: он позволяет синхронно читать состояние и корректно подписываться на обновления.

Подробнее — в документации React.

// use-typed-storage-item.ts
export function useTypedStorageItem<S extends TypedStorageValue, K extends Extract<keyof S, string>>(
  key: K,
  { storage }: { storage: TypedStorage<S>; }
) {
  const isClient = typeof window !== "undefined";
  const customEventName = `storage-${key}`;
  
  const subscribe = useCallback(
      (callback: () => void) => {
          if (!isClient) return noop;
  
          // использование callback мы напишем позже
      },
      [isClient],
  );
  
  const getSnapshot = useCallback(() => storage?.get(key) ?? null, [key, storage]);
  
  const value = useSyncExternalStore(
      subscribe,
      getSnapshot,
      () => null,
  );
  
  const set = useCallback(
      (val: S[K]) => storage.set(key, val),
      [key, storage],
  );
  
  const remove = useCallback(() => storage?.remove(key), [key, storage]);
  
  return useMemo(() => ({ value, set, remove }), [value, set, remove]);
}

Таким образом мы можем использовать этот хук в компоненте:

function ThemeToggle() {
  const { value, set } = useTypedStorageItem('theme', { storage: myStorage });
  
  return (
      <button onClick={() => set(value === 'light' ? 'dark' : 'light')}>
          Current mode: {value}
      </button>
  ); 
}

Шаг 3. Синхронизация между компонентами на одной странице

Как гарантировать, что при использовании хука в двух разных компонентах мы будем видеть одно и то же значение в value? У нас ведь два независимых хука, и каждый читает состояние сам.

Браузер позволяет отправлять кастомные события через window.dispatchEvent и подписываться на них через window.addEventListener. Пусть наша типизированная обёртка при изменении данных будет диспатчить событие, а каждый хук — слушать его. Тогда мы сможем синхронизировать состояние между компонентами на одной странице.

Добавим отправку событий в обёртку:

// typedStorage.ts
// ...
set<K extends Keys>(key: K, value: Value<K>): Value<K> | null {
  // ...
  storage.setItem(key, raw);
  if (isClient) {
    window.dispatchEvent(new CustomEvent(`storage-${key}`));
  }

  return value;
  // ...

},

remove(key: Keys): void {
  getStorage()?.removeItem(key);
  if (isClient) {
    window.dispatchEvent(new CustomEvent(`storage-${key}`));
  }
},

clear() {
  getStorage()?.clear();
  if (isClient) {
    window.dispatchEvent(new CustomEvent(`clear-storage`));
  }
},

По аналогии с событием изменения конкретного ключа добавим случай с очисткой всего хранилища.

Теперь реализуем subscribe внутри хука:

// use-typed-storage-item.ts
const subscribe = useCallback((callback: () => void) => {
  if (!isClient) return () => undefined;

  window.addEventListener(customEventName, callback);
  window.addEventListener('clear-storage', callback);

  return () => {
      window.removeEventListener(customEventName, callback);
      window.removeEventListener('clear-storage', callback);
  };
}, [key, customEventName, isClient],
);

Дальнейшие улучшения

Мы разобрали основные моменты, но здесь всё ещё есть пространство для улучшений. Давайте последовательно пройдёмся по ним: зачем они нужны и как их реализовать.

Улучшение 1. Синхронизация между вкладками/страницами

Сейчас компоненты синхронизируются внутри одной страницы. Но если значение изменится в другой вкладке или на другой странице, наше кастомное событие не сработает.

Для этого есть встроенное браузерное событие "storage", в котором передаётся изменённый ключ (или null, если хранилище было очищено). Добавим обработку этого события в subscribe:

// use-typed-storage-item.ts
const subscribe = useCallback((callback: () => void) => {
  if (!isClient) return () => undefined;

  const storageHandler = (e: StorageEvent) => {
    if (e.key === key || e.key === null) callback();
  };

  window.addEventListener("storage", storageHandler);

  return () => {
    window.removeEventListener("storage", storageHandler);
  };
}, [key, customEventName, isClient],
);

Таким образом наши кастомные события ловят изменения в рамках одной страницы same-tab, а встроенное событие "storage" изменения между страницами, cross-tab

Улучшение 2. Поддержка sessionStorage

Сейчас мы работаем только с localStorage. Но можно использовать тот же подход и для sessionStorage. Модифицируем фабрику, чтобы она поддерживала оба варианта:

// typedStorage.ts
type StorageType = "localStorage" | "sessionStorage";

function createTypedStorage(
  type: StorageType = "localStorage"
): TypedStorage {
  const getStorage = (): Storage | null => {
    if (!isClient) return null;
  
    return type === "localStorage"
        ? window.localStorage
        : window.sessionStorage;
  };
  // ...
}

Улучшение 3. Значение по умолчанию

Сейчас, если значение не задано, мы возвращаем null. Добавим поддержку значения по умолчанию в интерфейс:

export type TypedStorage<S extends TypedStorageValue> = {
  get<K extends Extract<keyof S, string>>(
    key: K,
    options?: { defaultValue?: S[K] | undefined; }
  ): S[K] | null;
  // ...
};

Улучшение 4. Валидация при чтении и записи

Хотя мы и знаем, что мы записываем в storage ожидаемые типы, мы не защищены от ситуации, когда данные уже лежат там (или были изменены вручную/расширениями/другим кодом).

Добавим опциональную валидацию:

// typedStorage.ts
type StorageValidator = (value: unknown) => T;

get(key: K, options?: { defaultValue?: Value; validate?: StorageValidator<S[K]>; }): Value | null {
  const storage = getStorage();
  if (!storage) return options?.defaultValue ?? null;
  
  try {
      const raw = storage.getItem(key);
      if (!raw) return options?.defaultValue ?? null;
  
      let parsed = JSON.parse(raw);
      if (options?.validate && parsed !== null) {
          parsed = options.validate(parsed);
      }
  
      return parsed;
  } catch (error) {
      console.warn(`Invalid data for key "${key}":`, error);
      return options?.defaultValue ?? null;
  }
},

set(key: K, value: Value, options?: { validate?: StorageValidator<S[K]> }): Value | null {
  const storage = getStorage();
  if (!storage) return null;
  
  try {
      const valueToSave = options?.validate ? options.validate(value) : value;
      const raw = JSON.stringify(valueToSave);
      storage.setItem(key, raw);
      if (isClient) {
          window.dispatchEvent(new CustomEvent(`storage-${key}`));
      }
  
      return valueToSave;
  } catch (error) {
      console.error(`Failed to save key "${key}":`, error);
      return null;
  }
},

Улучшение 5. Кэширование, чтобы избежать лишних ререндеров

Если в localStorage лежит объект, то при JSON.parse мы каждый раз получаем новый объект. React сравнивает объекты по ссылке, поэтому компонент может перерендериваться чаще, чем ожидается.

Добавим простой кэш: ключом будет выступать сериализованная строка, лежащая в storage. Если строка не изменилась — будем возвращать тот же распарсенный объект (с той же ссылкой).

const cache = new Map<string, { raw: string | null; parsed: any }>();

get(
  key: K,
  options?: { defaultValue?: Value; validate?: StorageValidator<S[K]>; },
): Value | null {
  // ...
  const raw = storage.getItem(key);
  const cached = cache.get(key);
  if (cached && cached.raw === raw) {
    return cached.parsed;
  }
  // ...
}

Главное — не забыть обновлять кэш при изменениях в storage.

Улучшение 6. React < 18 и useSyncExternalStore

useSyncExternalStore есть только в React 18+. Что делать со старыми версиями?

Как минимум, я бы рекомендовал обновляться. Но если по техническим причинам это невозможно — есть готовая библиотека use-sync-external-store/shim, которая даёт полифилл этого хука для более старых версий. В React 18+ она использует нативный useSyncExternalStore.

Финальная версия

Финальная версия со всеми исправлениями и нужным функционалом:

// typedStorage.ts
export function createTypedStorage(
  type: StorageType = "localStorage",
): TypedStorage {
  type Keys = Extract<keyof S, string>;
  type Value = S[K];
  const isClient = typeof window !== "undefined";
  const cache = new Map<string, { raw: string | null; parsed: any }>();
  
  const getStorage = (): Storage | null => {
      if (!isClient) return null;
  
      return type === "localStorage"
          ? window.localStorage
          : window.sessionStorage;
  };
  
  return {
      get<K extends Keys>(
          key: K,
          options?: { defaultValue?: Value<K>; validate?: StorageValidator<S[K]>; },
      ): Value<K> | null {
          const storage = getStorage();
          if (!storage) return options?.defaultValue ?? null;
  
          try {
              const raw = storage.getItem(key);
              const cached = cache.get(key);
              if (cached && cached.raw === raw) {
                  return cached.parsed;
              }
  
              if (!raw) return options?.defaultValue ?? null;
  
              let parsed = JSON.parse(raw);
              if (options?.validate && parsed !== null) {
                  parsed = options.validate(parsed);
              }
  
              cache.set(key, { raw, parsed });
              return parsed;
          } catch (error) {
              console.warn(`[${type}] Invalid data for key "${key}":`, error);
              return options?.defaultValue ?? null;
          }
      },
  
      set<K extends Keys>(key: K, value: Value<K>, options?: { validate?: StorageValidator<S[K]> }): Value<K> | null {
          const storage = getStorage();
          if (!storage) return null;
  
          try {
              const valueToSave = options?.validate ? options.validate(value) : value;
              const raw = JSON.stringify(valueToSave);
              storage.setItem(key, raw);
              cache.set(key, { raw, parsed: valueToSave });
              if (isClient) {
                  window.dispatchEvent(new CustomEvent(`storage-${key}`));
              }
  
              return valueToSave;
          } catch (error) {
              console.error(`[${type}] Failed to save key "${key}":`, error);
              return null;
          }
      },
  
      remove(key: Keys): void {
          getStorage()?.removeItem(key);
          cache.delete(key);
          if (isClient) {
              window.dispatchEvent(new CustomEvent(`storage-${key}`));
          }
      },
  
      clear() {
          getStorage()?.clear();
          cache.clear();
          if (isClient) {
              window.dispatchEvent(new CustomEvent(‘clear-storage’));
          }
      },
  };
}
// use-typed-storage-item.ts
export function useTypedStorageItem<S extends TypedStorageValue, K extends Extract<keyof S, string>>(
  key: K,
  { storage, defaultValue, validate }: { storage: TypedStorage; defaultValue?: S[K] | undefined; validate?: StorageValidator<S[K]>; },
) {
  const isClient = typeof window !== "undefined";
  const customEventName = `storage-${key}`;
  
  const subscribe = useCallback(
      (callback: () => void) => {
          if (!isClient) return noop;
  
          const storageHandler = (e: StorageEvent) => {
              if (e.key === key || e.key === null) callback();
          };
  
          window.addEventListener("storage", storageHandler);
          window.addEventListener(`storage-${key}`, callback);
          window.addEventListener("clear-storage", callback);
  
          return () => {
              window.removeEventListener("storage", storageHandler);
              window.removeEventListener(`storage-${key}`, callback);
              window.removeEventListener("clear-storage", callback);
          };
      },
      [key, customEventName, isClient],
  );
  
  const getSnapshot = useCallback(() => storage?.get(key, { defaultValue, validate }) ?? null, [key, storage, defaultValue, validate]);
  
  const value = useSyncExternalStore(
      subscribe,
      getSnapshot,
      () => defaultValue ?? null,
  );
  
  const set = useCallback(
      (val: S[K]) => storage.set(key, val, { validate }),
      [key, storage, customEventName],
  );
  
  const remove = useCallback(() => storage?.remove(key), [key, storage, customEventName]);
  
  return useMemo(() => ({ value, set, remove }), [value, set, remove]); 
}

Заключение

Да, localStorage не является идеальным хранилищем. Он не предназначен для больших объёмов данных, может быть изменён пользователем, может быть медленным, и ни в коем случае не является заменой Redux/Zustand или других библиотек управления состоянием.

Но при этом он всё ещё полезен в некоторых сценариях — главное, использовать его более безопасно и удобно.

Всё, что описано в этой статье, я вынес в небольшую библиотеку, полностью совместимую с SSR и работающую с любой версией React старше 16, чтобы не дублировать эту логику в каждом проекте.

Вы можете установить её через npm i use-sync-typed-storage или посмотреть исходники на GitHub.

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


  1. DmitryOlkhovoi
    14.01.2026 18:50

    сейчас практически любой стейт менеджер из коробки или плагином умеет в локалстор. Да и даже если просто, не стоит писать велосипед. Если не хватает нативного апи, лучше взять какое-то готовое решение.


  1. themen2
    14.01.2026 18:50

    Реакт код реально какое то месиво сам по себе))


  1. adminNiochen
    14.01.2026 18:50

    Ой как удобно в изначальном примере добавить юзэффект который асинхронно исполняется, а потом искать серебряную пулю чтобы решить проблему с мигающим интерфейсом которую сами же создали. useState умеет принимать функцию-инициализатор если что, useLayoutEffect тоже существует.

    Ну и чо-то я не понял как будет типизация работать если ты по одному ключу разные данные попытаешься записать? Локал сторадж-то общий. Если у тебя один компонент пишет поле counter и другой пишет counter то приплыли. Или ещё лучше - два экземпляра одного и того же компонента.


    1. hardlight Автор
      14.01.2026 18:50

      Первый пример намеренно гипертрофирован и показывает худший случай, да. Ниже указаны какие могут быть проблемы в целом при работе с local storage напрямую и через хуки, в том числе и через useState и useLayoutEffect.

      Про два экземпляра одного и того же компонента, то как раз эта статья показывает как сделать чтобы при обновлении состояния в одном компоненте, второй получал то же значение, так же как если бы это было сделано через общий стор в redux или zustand. Если нужно чтобы два экземпляра одного и того же компонента считали counter независимо друг от друга, то стоит использовать локальный стейт через useState, а не local storage