Рефакторим useLocalStorage

14 просмотров
Введение

Просматривая старые проекты на github, наткнулся на свой React-хук useLocalStorage. Писал я его давно, когда только начал смотреть на React, и, возможно, по каким-то гайдам или статьям на StackOverflow. Но такой хук часто встречается и в рабочих проектах.

Код совсем небольшой. Попробуйте сходу найти в нем проблемы:

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error("Ошибка при чтении из localStorage", error);
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error("Ошибка при записи в localStorage", error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

Я провел небольшой анализ, используя поиск по GitHub и другим источникам, и могу сказать, что в случайных репозиториях, которые я открывал, в 4 из 5 случаев хуки useLocalStorage и useSessionStorage содержат одни и те же проблемы. В этой статье попробуем с ними разобраться и напишем нормальный хук для работы со storage.

Для чего этот хук?

LocalStorage и SessionStorage — довольно похожие браузерные API. Их отличия заключаются только во времени жизни и доступности. Но с ними довольно неудобно работать напрямую по следующим причинам:

  • Хранят только строки — нужно каждый раз парсить и сериализовать данные
  • Нужно всегда обрабатывать ошибки
  • Отсутствие реактивности — потребители данных не знают об их изменении
  • Нет типизации из коробки
Недостатки популярной реализации

Разберем проблемы хука из начала статьи.

Одинаковые ключи с разными типами данных

Это довольно частая проблема, особенно в больших проектах. Один и тот же ключ может использоваться в разных частях приложения с разными типами данных. Например, в одном месте сохраняем строку, в другом — объект. Или один компонент использует ключ user, а другой — тот же ключ user, но для других данных.

При переключении контекста это может привести к падению при JSON.parse или использованию неверных типов.

В простой реализации нет механизма изоляции ключей или предупреждений о возможных конфликтах. Так же тут помогла бы типизация всех доступных в проекте ключей или система префиксов.

Рассинхронизация состояния

Отсутствие реактивности между компонентами и вкладками браузера. Если вы вызовете хук в ComponentA и ComponentB, и один из них обновит значение, второй ничего об этом не узнает и продолжит показывать старые данные.

Казалось бы, можно просто подписаться на встроенное браузерное событие storage. Но тут кроется главный подвох этого API. В документации события storage на MDN написано следующее:

Получается по задумке, если скрипт в текущей вкладке делает localStorage.setItem('key', 'value'), он уже знает, что данные изменились и браузеру не нужно бросать событие в ту же самую вкладку. По задумке, событие storage создавалось исключительно как механизм межвкладочной коммуникации, чтобы другие открытые вкладки того же сайта могли синхронизировать свое состояние.

В итоге получается, что вкладки отлично общаются между собой, но компоненты на одной странице не знают об изменениях в storage которые инициировали другие компоненты. Именно поэтому нам придется строить собственный механизм уведомлений.

Жесткая привязка к формату и хранилищу

Встроенный JSON.stringify отлично справляется с примитивами и простыми объектами, но что если мы захотим передать данные с Map, Set или объектами Date. Простой хук не дает возможности вмешаться в этот процесс. Кроме того, иногда нам нужен sessionStorage, а реализация намертво завязана на глобальный window.localStorage.

"Тихий" сброс ошибок

Заглушать ошибки через жестко зашитый console.error не редко встречается в проектах, хоть это и плохая практика. Обычно в проекте ошибку логируют и отправляют в систему мониторинга. По этому хорошем вариантом тут будет иметь возможность переопределить логер на уровне приложения. Таким образом мы сможем прокинуть любую его реализацию внутрь хука.


Проектируем улучшенный хук

С учетом этих проблем спроектируем API нового хука. Нам понадобятся:

  • Гибкая типизация.
  • Возможность выбрать тип хранилища (localStorage или sessionStorage).
  • Кастомные функции для сериализации и десериализации.
  • Кастомный логгер.
  • Синхронизация состояния между компонентами в одной вкладке и между разными вкладками.

Для начала определим интерфейс опций, которые может принимать наш хук:

interface StorageOptions<T> {
  // По умолчанию localStorage
  storage?: 'localStorage' | 'sessionStorage';
  // Кастомная сериализация
  serializer?: (value: T) => string;
  // Кастомная десериализация
  deserializer?: (value: string) => T;
  // Логгер для обработки ошибок
  logger?: (error: unknown) => void;
}
Решаем проблему реактивности

Для синхронизации компонентов внутри одной вкладки для примера попробуем реализовать свой небольшой механизм подписок (Event Emitter). Если у нас много ключей, мы не хотим вешать десятки слушателей на window. Хватит одного глобального слушателя, который будет уведомлять нужных подписчиков, и своего менеджера для рассылки событий внутри текущей вкладки.

Подписку на событие реализуем с помощью комбинации useState и useEffect. Компонент будет подписываться на наш Emitter внутри useEffect, и когда срабатывает событие и нужное нам значение из LocalStorage меняется, вызываем setState, чтобы спровоцировать рендер.

В React 18 появился хук useSyncExternalStore, который идеально решает эту задачу и избавляет от необходимости жонглировать эффектами. Он как раз создан для подписки компонентов на внешние (по отношению к React) источники данных.

Для особо сложных кейсов синхронизации вкладок иногда применяют BroadcastChannel, но для базовой работы со Storage связки из нашего Event Emitter и нативного события storage будет более чем достаточно.

Напишем простой менеджер подписок:

type Listener = () => void;

class StorageEventManager {
  private listeners = new Map<string, Set<Listener>>();

  subscribe(key: string, listener: Listener) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, new Set());
    }
    this.listeners.get(key)!.add(listener);
    
    return () => {
      this.listeners.get(key)?.delete(listener);
    };
  }

  notify(key: string) {
    this.listeners.get(key)?.forEach(listener => listener());
  }
}

const storageEvents = new StorageEventManager();

// Глобальный слушатель для синхронизации между вкладками
if (typeof window !== 'undefined') {
  window.addEventListener('storage', (event) => {
    // Событие storage имеет свойство key, указывающее на измененный ключ
    if (event.key) {
      storageEvents.notify(event.key);
    }
  });
}
Собираем всё воедино

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

import { useCallback, useSyncExternalStore } from 'react';

export function useStorage<T>(
  key: string, 
  initialValue: T, 
  options: StorageOptions<T> = {}
) {
  const {
    storage = 'localStorage',
    serializer = JSON.stringify,
    deserializer = JSON.parse,
    logger = console.error,
  } = options;

  // Безопасное получение хранилища (с проверкой для SSR)
  const getStorage = () => {
    if (typeof window === 'undefined') return null;
    return window[storage];
  };

  // Функция для чтения текущего значения
  const getSnapshot = useCallback(() => {
    const store = getStorage();
    if (!store) return initialValue;

    try {
      const item = store.getItem(key);
      // Если ключа нет, возвращаем initialValue
      if (item === null) return initialValue;
      return deserializer(item);
    } catch (error) {
      logger(error);
      return initialValue;
    }
  }, [key, initialValue, deserializer, logger, storage]);

  // Подписка на изменения
  const subscribe = useCallback((listener: () => void) => {
    return storageEvents.subscribe(key, listener);
  }, [key]);

  // Используем useSyncExternalStore для реактивности
  const value = useSyncExternalStore(subscribe, getSnapshot, () => initialValue);

  // Функция для записи нового значения
  const setValue = useCallback((newValue: T | ((val: T) => T)) => {
    try {
      const store = getStorage();
      if (!store) return;

      const valueToStore = newValue instanceof Function ? newValue(value) : newValue;
      store.setItem(key, serializer(valueToStore));
      
      // Уведомляем другие компоненты в ТЕКУЩЕЙ вкладке
      storageEvents.notify(key);
    } catch (error) {
      logger(error);
    }
  }, [key, value, serializer, logger, storage]);

  const removeValue = useCallback(() => {
    try {
      const store = getStorage();
      if (!store) return;
      
      store.removeItem(key);
      storageEvents.notify(key);
    } catch (error) {
      logger(error);
    }
  }, [key, logger, storage]);

  return [value, setValue, removeValue] as const;
}
Что получилось в итоге
  1. Благодаря дженерикам (const [user, setUser] = useStorage<User>('user', defaultUser)) TypeScript проконтролирует типы сохраняемых данных.
  2. Любое изменение через setValue моментально обновит все компоненты, использующие этот ключ, даже если они находятся в другой вкладке.
  3. На объекте window висит ровно один слушатель события storage, а локальные подписки разводятся через эффективный StorageEventManager.
  4. Теперь мы можем передать кастомный logger, а если нам нужно хранить Date, мы просто передадим свои serializer и deserializer. Поддерживается как localStorage, так и sessionStorage.
  5. Хук не упадет с ошибкой window is not defined при серверном рендеринге.

Если копнуть глубже в ограничения Web Storage API, можно наткнуться на нехватку места, строгие настройки приватности и агрессивный Safari ITP. О реальных проблемах браузерных хранилищ я подробно писал в статье «Хранение данных в браузере». Обязательно закладывайте fallbacks на случай недоступности хранилища!

Идеи фич, которые еще можно добавить над хуком:

  • Типизацию или менеджер всех доступных в проекте ключей
  • Систему префиксов для ключей
  • Версионирование данных, которые записываются в storage
  • Время жизни данных, записанных в storage
  • Debounce/throttle записи при высокой частоте изменений
  • И многое другое...

На этом все, спасибо за внимание! Пишите надежные хуки, успехов!

К списку статей