use-storage-state
Version:
React hook that you can wire with any Storage compatible API like `localStorage`, `sessionStorage`, or a custom one.
133 lines (132 loc) • 5.38 kB
JavaScript
import memoryStorage from "./memoryStorage.js";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
export default function useStorageState(key, options) {
const serializer = options?.serializer;
const [defaultValue] = useState(options?.defaultValue);
const storageOption = options !== undefined && "storage" in options
? options.storage
: "local";
const storageObj = storageOption === "local"
? goodTry(() => localStorage)
: storageOption === "session"
? goodTry(() => sessionStorage)
: storageOption;
const resolvedStorage = storageObj === undefined && options?.memoryFallback !== false
? memoryStorage
: storageObj;
return useStorage(key, defaultValue, resolvedStorage, options?.sync, options?.storeDefault, serializer?.parse, serializer?.stringify);
}
function useStorage(key, defaultValue, storage, sync = true, storeDefault = false, parse = parseJSON, stringify = JSON.stringify) {
// we keep the `parsed` value in a ref because `useSyncExternalStore` requires a cached version
const storageItem = useRef({
string: null,
parsed: defaultValue,
});
const value = useSyncExternalStore(
// useSyncExternalStore.subscribe
useCallback((onStoreChange) => {
const onChange = (localKey) => {
if (key === localKey) {
onStoreChange();
}
};
callbacks.add(onChange);
return () => {
callbacks.delete(onChange);
};
}, [key]),
// useSyncExternalStore.getSnapshot
() => {
const string = storage === undefined
? null
: goodTry(() => storage.getItem(key)) ?? null;
if (string !== storageItem.current.string) {
let parsed;
try {
parsed =
string === null ? defaultValue : parse(string);
}
catch {
parsed = defaultValue;
}
storageItem.current.parsed = parsed;
}
storageItem.current.string = string;
// related issues:
// - https://github.com/astoilkov/use-local-storage-state/issues/26
// - https://github.com/astoilkov/use-storage-state/issues/1
if (storeDefault &&
string === null &&
storage !== undefined &&
defaultValue !== undefined) {
// reasons for `localStorage` to throw an error:
// - maximum quota is exceeded
// - under Mobile Safari (since iOS 5) when the user enters private mode
// `localStorage.setItem()` will throw
// - trying to access localStorage object when cookies are disabled in Safari throws
// "SecurityError: The operation is insecure."
// eslint-disable-next-line no-console
goodTry(() => {
const string = stringify(defaultValue);
storage.setItem(key, string);
storageItem.current = { string, parsed: defaultValue };
});
}
return storageItem.current.parsed;
},
// useSyncExternalStore.getServerSnapshot
() => defaultValue);
const setState = useCallback((newValue) => {
const value = newValue instanceof Function
? newValue(storageItem.current.parsed)
: newValue;
// reasons for `localStorage` to throw an error:
// - maximum quota is exceeded
// - under Mobile Safari (since iOS 5) when the user enters private mode
// `localStorage.setItem()` will throw
// - trying to access `localStorage` object when cookies are disabled in Safari throws
// "SecurityError: The operation is insecure."
goodTry(() => storage?.setItem(key, stringify(value)));
triggerCallbacks(key);
}, [key, storage, stringify]);
const removeItem = useCallback(() => {
if (storage !== undefined) {
goodTry(() => storage.removeItem(key));
triggerCallbacks(key);
}
}, [key, storage]);
// - syncs change across tabs, windows, iframes
// - the `storage` event is called only in all tabs, windows, iframe's except the one that
// triggered the change
useEffect(() => {
if (!sync) {
return undefined;
}
const onStorage = (e) => {
if (e.key === key && e.storageArea === goodTry(() => storage)) {
triggerCallbacks(key);
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key, storage, sync]);
return useMemo(() => [value, setState, removeItem], [value, setState, removeItem]);
}
// notifies all instances using the same `key` to update
const callbacks = new Set();
function triggerCallbacks(key) {
for (const callback of [...callbacks]) {
callback(key);
}
}
// a wrapper for `JSON.parse()` that supports "undefined" value. otherwise,
// `JSON.parse(JSON.stringify(undefined))` returns the string "undefined" not the value `undefined`
function parseJSON(value) {
return value === "undefined" ? undefined : JSON.parse(value);
}
function goodTry(tryFn) {
try {
return tryFn();
}
catch { }
}