UNPKG

@react-hookz/web

Version:

React hooks done right, for browser and SSR.

200 lines (199 loc) 7.42 kB
/* eslint-disable @typescript-eslint/no-use-before-define,no-use-before-define */ import { useCallback } from 'react'; import { useConditionalEffect, useFirstMountState, useIsomorphicLayoutEffect, useMountEffect, usePrevious, useSafeState, useSyncedRef, useUpdateEffect, } from '..'; import { resolveHookState } from "../util/resolveHookState.js"; import { isBrowser } from "../util/const.js"; import { off, on } from "../util/misc.js"; /** * Manages a single storage key. * * @param storage Storage instance that will be managed * @param key Storage key to manage * @param defaultValue Default value to yield in case the key is not in storage * @param options */ export function useStorageValue(storage, key, defaultValue = null, options = {}) { const { isolated } = options; let { initializeWithStorageValue = true, handleStorageEvent = true, storeDefaultValue = false, } = options; // avoid fetching data from storage during SSR if (!isBrowser) { storeDefaultValue = false; initializeWithStorageValue = false; handleStorageEvent = false; } // needed to provide stable API const methods = useSyncedRef({ fetchVal: () => parse(storage.getItem(key), defaultValue), storeVal: (val) => { const stringified = stringify(val); if (stringified) { storage.setItem(key, stringified); return true; } return false; }, removeVal: () => { storage.removeItem(key); }, setVal: (val) => { setState(parse(val, defaultValue)); }, fetchState: () => { const newVal = methods.current.fetchVal(); setState(newVal); return newVal !== stateRef.current ? newVal : null; }, setState: (nextState) => { setState(nextState === null ? defaultValue : nextState); }, }); const isFirstMount = useFirstMountState(); const [state, setState] = useSafeState(initializeWithStorageValue && isFirstMount ? methods.current.fetchVal() : undefined); const prevState = usePrevious(state); const stateRef = useSyncedRef(state); const keyRef = useSyncedRef(key); const isolatedRef = useSyncedRef(isolated); // fetch value on mount for the case `initializeWithStorageValue` is false, // effects are not invoked during SSR, so there is no need to check isBrowser here useMountEffect(() => { if (!initializeWithStorageValue) { methods.current.fetchState(); } }); // store default value if it is not null and options configured to store default value useConditionalEffect(() => { methods.current.storeVal(defaultValue); }, undefined, [prevState !== state, storeDefaultValue && state === defaultValue && defaultValue !== null]); // refetch value when key changed useUpdateEffect(() => { methods.current.fetchState(); }, [key]); // subscribe hook for storage events useIsomorphicLayoutEffect(() => { if (!handleStorageEvent) return; // eslint-disable-next-line unicorn/consistent-function-scoping const storageHandler = (ev) => { if (ev.storageArea !== storage) return; if (ev.key !== keyRef.current) return; methods.current.setVal(ev.newValue); }; on(window, 'storage', storageHandler, { passive: true }); return () => { off(window, 'storage', storageHandler); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [handleStorageEvent]); // register hook for same-page synchronisation useIsomorphicLayoutEffect(() => { if (isolated) return; let storageKeys = storageKeysUsed.get(storage); if (!storageKeys) { storageKeys = new Map(); storageKeysUsed.set(storage, storageKeys); } let keySetters = storageKeys.get(key); if (!keySetters) { keySetters = new Set(); storageKeys.set(key, keySetters); } const mSetState = methods.current.setState; keySetters.add(mSetState); return () => { keySetters?.delete(mSetState); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isolated, key]); return [ state, useCallback((newState) => { if (!isBrowser) return; const s = resolveHookState(newState, stateRef.current); if (methods.current.storeVal(s)) { methods.current.setState(s); if (!isolatedRef.current) { // update all other hooks state storageKeysUsed .get(storage) ?.get(keyRef.current) ?.forEach((setter) => { if (setter === methods.current.setState) return; setter(s); }); } } }, // eslint-disable-next-line react-hooks/exhaustive-deps []), useCallback(() => { if (!isBrowser) return; methods.current.removeVal(); methods.current.setState(null); if (!isolatedRef.current) { // update all other hooks state storageKeysUsed .get(storage) ?.get(keyRef.current) ?.forEach((setter) => { if (setter === methods.current.setState) return; setter(null); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []), useCallback(() => { if (!isBrowser) return; const newVal = methods.current.fetchState(); if (newVal !== null && !isolatedRef.current) { // update all other hooks state storageKeysUsed .get(storage) ?.get(keyRef.current) ?.forEach((setter) => { if (setter === methods.current.setState) return; setter(newVal); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []), ]; } const storageKeysUsed = new Map(); const stringify = (data) => { if (data === null) { // eslint-disable-next-line no-console console.warn(`'null' is not a valid data for useStorageValue hook, this operation will take no effect`); return null; } try { return JSON.stringify(data); } catch (error) /* istanbul ignore next */ { // i have absolutely no idea how to cover this, since modern JSON.stringify does not throw on // cyclic references anymore // eslint-disable-next-line no-console console.warn(error); return null; } }; const parse = (str, fallback) => { if (str === null) return fallback; try { return JSON.parse(str); } catch (error) { // eslint-disable-next-line no-console console.warn(error); return fallback; } };