Просматривая старые проекты на 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;
}
Что получилось в итоге
- Благодаря дженерикам (
const [user, setUser] = useStorage<User>('user', defaultUser)) TypeScript проконтролирует типы сохраняемых данных. - Любое изменение через
setValueмоментально обновит все компоненты, использующие этот ключ, даже если они находятся в другой вкладке. - На объекте
windowвисит ровно один слушатель событияstorage, а локальные подписки разводятся через эффективныйStorageEventManager. - Теперь мы можем передать кастомный
logger, а если нам нужно хранитьDate, мы просто передадим своиserializerиdeserializer. Поддерживается какlocalStorage, так иsessionStorage. - Хук не упадет с ошибкой
window is not definedпри серверном рендеринге.
Если копнуть глубже в ограничения Web Storage API, можно наткнуться на нехватку места, строгие настройки приватности и агрессивный Safari ITP. О реальных проблемах браузерных хранилищ я подробно писал в статье «Хранение данных в браузере». Обязательно закладывайте fallbacks на случай недоступности хранилища!
Идеи фич, которые еще можно добавить над хуком:
- Типизацию или менеджер всех доступных в проекте ключей
- Систему префиксов для ключей
- Версионирование данных, которые записываются в storage
- Время жизни данных, записанных в storage
- Debounce/throttle записи при высокой частоте изменений
- И многое другое...
На этом все, спасибо за внимание! Пишите надежные хуки, успехов!