|
| 1 | +import {useCallback, useEffect, useSyncExternalStore} from 'react'; |
| 2 | +import type {AnyObj, Fn} from 'beeftools'; |
| 3 | + |
| 4 | +// Adapted from: |
| 5 | +// https://usehooks.com/uselocalstorage |
| 6 | + |
| 7 | +type AcceptedTypes = string | number | boolean | AnyObj; |
| 8 | +type AcceptedFn = (arg: AnyObj) => AcceptedTypes; |
| 9 | + |
| 10 | +type LocalStorageValue = AcceptedTypes | AcceptedTypes[] | AcceptedFn; |
| 11 | +type ServerSnapshotFn = Parameters<typeof useSyncExternalStore>[2]; |
| 12 | + |
| 13 | +type LocalStorageReturn = [ |
| 14 | + state: LocalStorageValue, |
| 15 | + setter: (value: LocalStorageValue) => void, |
| 16 | +]; |
| 17 | + |
| 18 | +function dispatchStorageEvent(key: string, newValue?: string | null) { |
| 19 | + window.dispatchEvent(new StorageEvent('storage', {key, newValue})); |
| 20 | +} |
| 21 | + |
| 22 | +function parseStore(store: unknown) { |
| 23 | + return JSON.parse(store as string) as AnyObj; |
| 24 | +} |
| 25 | + |
| 26 | +function setLocalStorageItem(key: string, value: LocalStorageValue) { |
| 27 | + const stringifiedValue = JSON.stringify(value); |
| 28 | + |
| 29 | + window.localStorage.setItem(key, stringifiedValue); |
| 30 | + dispatchStorageEvent(key, stringifiedValue); |
| 31 | +} |
| 32 | + |
| 33 | +function removeLocalStorageItem(key: string) { |
| 34 | + window.localStorage.removeItem(key); |
| 35 | + dispatchStorageEvent(key, null); |
| 36 | +} |
| 37 | + |
| 38 | +function getLocalStorageItem(key: string) { |
| 39 | + return window.localStorage.getItem(key); |
| 40 | +} |
| 41 | + |
| 42 | +const getLocalStorageServerSnapshot: ServerSnapshotFn = () => { |
| 43 | + throw Error('useLocalStorage is a client-only hook'); |
| 44 | +}; |
| 45 | + |
| 46 | +function useLocalStorageSubscribe(callback: Fn) { |
| 47 | + window.addEventListener('storage', callback); |
| 48 | + |
| 49 | + return () => { |
| 50 | + window.removeEventListener('storage', callback); |
| 51 | + }; |
| 52 | +} |
| 53 | + |
| 54 | +// TODO: Fix this to accept a `generic` / infer the correct type from `initialValue`. |
| 55 | +// For now, I prefer using the `TypedStorage` class. |
| 56 | +export function useLocalStorage(key: string, initialValue: LocalStorageValue) { |
| 57 | + const getSnapshot = () => getLocalStorageItem(key); |
| 58 | + |
| 59 | + const store = useSyncExternalStore( |
| 60 | + useLocalStorageSubscribe, |
| 61 | + getSnapshot, |
| 62 | + getLocalStorageServerSnapshot, |
| 63 | + ); |
| 64 | + |
| 65 | + // Handles both `set` and `remove`. By passing `undefined` or `null`, |
| 66 | + // `removeLocalStorageItem` is called on the provided `key`. |
| 67 | + const setState = useCallback( |
| 68 | + (value: LocalStorageValue) => { |
| 69 | + try { |
| 70 | + const isFn = typeof value === 'function'; |
| 71 | + const parsed = isFn ? parseStore(store) : undefined; |
| 72 | + const nextState = parsed ?? value; |
| 73 | + |
| 74 | + if (nextState === undefined || nextState === null) { |
| 75 | + removeLocalStorageItem(key); |
| 76 | + } else { |
| 77 | + setLocalStorageItem(key, nextState); |
| 78 | + } |
| 79 | + } catch (error) { |
| 80 | + console.warn(error); |
| 81 | + } |
| 82 | + }, |
| 83 | + [key, store], |
| 84 | + ); |
| 85 | + |
| 86 | + useEffect(() => { |
| 87 | + if ( |
| 88 | + getLocalStorageItem(key) === null && |
| 89 | + typeof initialValue !== 'undefined' |
| 90 | + ) { |
| 91 | + setLocalStorageItem(key, initialValue); |
| 92 | + } |
| 93 | + }, [key, initialValue]); |
| 94 | + |
| 95 | + const finalValue = store ? parseStore(store) : initialValue; |
| 96 | + const finalTuple: LocalStorageReturn = [finalValue, setState]; |
| 97 | + |
| 98 | + return finalTuple; |
| 99 | +} |
0 commit comments