@react-hookz/web
Version:
React hooks done right, for browser and SSR.
200 lines (199 loc) • 7.42 kB
JavaScript
/* 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;
}
};