465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
import {
|
|
Dispatch,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
// --- COMMON HELPERS AND TYPES (from original code) ---
|
|
|
|
const IS_SERVER = typeof window === "undefined";
|
|
|
|
const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect;
|
|
|
|
function useEventCallback<Args extends unknown[], R>(
|
|
fn: ((...args: Args) => R) | undefined,
|
|
): ((...args: Args) => R) | undefined {
|
|
const ref = useRef<typeof fn>(() => {
|
|
throw new Error("Cannot call an event handler while rendering.");
|
|
});
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
ref.current = fn;
|
|
}, [fn]);
|
|
|
|
return useCallback((...args: Args) => ref.current?.(...args), [ref]) as (
|
|
...args: Args
|
|
) => R;
|
|
}
|
|
|
|
function useEventListener<
|
|
KW extends keyof WindowEventMap,
|
|
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
|
|
KM extends keyof MediaQueryListEventMap,
|
|
T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
|
|
>(
|
|
eventName: KW | KH | KM,
|
|
handler: (
|
|
event:
|
|
| WindowEventMap[KW]
|
|
| HTMLElementEventMap[KH]
|
|
| SVGElementEventMap[KH]
|
|
| MediaQueryListEventMap[KM]
|
|
| Event,
|
|
) => void,
|
|
element?: React.RefObject<T>,
|
|
options?: boolean | AddEventListenerOptions,
|
|
) {
|
|
const savedHandler = useRef(handler);
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
savedHandler.current = handler;
|
|
}, [handler]);
|
|
|
|
useEffect(() => {
|
|
const targetElement: T | Window = element?.current ?? window;
|
|
if (!(targetElement && targetElement.addEventListener)) return;
|
|
|
|
const listener: typeof handler = (event) => {
|
|
savedHandler.current(event);
|
|
};
|
|
|
|
targetElement.addEventListener(eventName, listener, options);
|
|
|
|
return () => {
|
|
targetElement.removeEventListener(eventName, listener, options);
|
|
};
|
|
}, [eventName, element, options]);
|
|
}
|
|
|
|
declare global {
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
interface WindowEventMap {
|
|
"local-storage": CustomEvent;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Base options for storage hooks.
|
|
* @template T - The type of the state to be stored.
|
|
*/
|
|
type BaseStorageOptions<T> = {
|
|
/** A function to serialize the value before storing it. */
|
|
serializer?: (value: T) => string;
|
|
/** A function to deserialize the stored value. */
|
|
deserializer?: (value: string) => T;
|
|
/**
|
|
* If `true` (default), the hook will initialize reading from storage.
|
|
* In SSR, you should set it to `false`, returning the initial value initially.
|
|
* @default true
|
|
*/
|
|
initializeWithValue?: boolean;
|
|
};
|
|
|
|
// --- INDEXEDDB ADAPTER (ToyStore) ---
|
|
|
|
/**
|
|
* This class provides an interface for setting, getting, and removing values from IndexedDB.
|
|
*/
|
|
export class ToyStore {
|
|
private dbName: string;
|
|
private storeName: string;
|
|
|
|
constructor(dbName: string = "ToyStoreDB", storeName: string = "ToyStore") {
|
|
this.dbName = dbName;
|
|
this.storeName = storeName;
|
|
}
|
|
|
|
private initDB(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
if (IS_SERVER) {
|
|
return reject(new Error("IndexedDB is not available in SSR."));
|
|
}
|
|
const request = indexedDB.open(this.dbName);
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
db.createObjectStore(this.storeName);
|
|
}
|
|
};
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async set(key: string, value: unknown): Promise<void> {
|
|
const db = await this.initDB();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(this.storeName, "readwrite");
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.put(value, key);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async get<T>(key: string): Promise<T | undefined> {
|
|
const db = await this.initDB();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(this.storeName, "readonly");
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.get(key);
|
|
request.onsuccess = () => resolve(request.result as T);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async remove(key: string): Promise<void> {
|
|
const db = await this.initDB();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(this.storeName, "readwrite");
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.delete(key);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- HOOK 1: useLocalStorage (Original) ---
|
|
|
|
/**
|
|
* Custom hook that uses the [`localStorage API`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to persist state across page reloads.
|
|
* @template T - The type of the state to be stored in local storage.
|
|
* @param {string} key - The key under which the value will be stored in local storage.
|
|
* @param {T | (() => T)} initialValue - The initial value of the state or a function that returns the initial value.
|
|
* @param {UseLocalStorageOptions<T>} [options] - Options for customizing the behavior of serialization and deserialization (optional).
|
|
* @returns {[T, Dispatch<SetStateAction<T>>, () => void]} A tuple containing the stored value, a function to set the value and a function to remove the key from storage.
|
|
* @public
|
|
* @see [Documentation](https://usehooks-ts.com/react-hook/use-local-storage)
|
|
* @example
|
|
* ```tsx
|
|
* const [count, setCount, removeCount] = useLocalStorage('count', 0);
|
|
* // Access the `count` value, the `setCount` function to update it and `removeCount` function to remove the key from storage.
|
|
* ```
|
|
*/
|
|
export function useLocalStorage<T>(
|
|
key: string,
|
|
initialValue: T | (() => T),
|
|
options: BaseStorageOptions<T> = {},
|
|
): [T, Dispatch<SetStateAction<T>>, () => void] {
|
|
const { initializeWithValue = true } = options;
|
|
|
|
const serializer = useCallback<(value: T) => string>(
|
|
(value) => {
|
|
if (options.serializer) {
|
|
return options.serializer(value);
|
|
}
|
|
|
|
return JSON.stringify(value);
|
|
},
|
|
[options],
|
|
);
|
|
|
|
const deserializer = useCallback<(value: string) => T>(
|
|
(value) => {
|
|
if (options.deserializer) {
|
|
return options.deserializer(value);
|
|
}
|
|
// Support 'undefined' as a value
|
|
if (value === "undefined") {
|
|
return undefined as unknown as T;
|
|
}
|
|
|
|
const defaultValue = initialValue instanceof Function
|
|
? initialValue()
|
|
: initialValue;
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch (error) {
|
|
console.error("Error parsing JSON:", error);
|
|
return defaultValue; // Return initialValue if parsing fails
|
|
}
|
|
|
|
return parsed as T;
|
|
},
|
|
[options, initialValue],
|
|
);
|
|
|
|
// Get from local storage then
|
|
// parse stored json or return initialValue
|
|
const readValue = useCallback((): T => {
|
|
const initialValueToUse = initialValue instanceof Function
|
|
? initialValue()
|
|
: initialValue;
|
|
|
|
// Prevent build error "window is undefined" but keep working
|
|
if (IS_SERVER) {
|
|
return initialValueToUse;
|
|
}
|
|
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
return raw ? deserializer(raw) : initialValueToUse;
|
|
} catch (error) {
|
|
console.warn(`Error reading localStorage key “${key}”:`, error);
|
|
return initialValueToUse;
|
|
}
|
|
}, [initialValue, key, deserializer]);
|
|
|
|
const [storedValue, setStoredValue] = useState(() => {
|
|
if (initializeWithValue) {
|
|
return readValue();
|
|
}
|
|
|
|
return initialValue instanceof Function ? initialValue() : initialValue;
|
|
});
|
|
|
|
// Return a wrapped version of useState's setter function that ...
|
|
// ... persists the new value to localStorage.
|
|
const setValue: Dispatch<SetStateAction<T>> = useEventCallback((value) => {
|
|
// Prevent build error "window is undefined" but keeps working
|
|
if (IS_SERVER) {
|
|
console.warn(
|
|
`Tried setting localStorage key “${key}” even though environment is not a client`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Allow value to be a function so we have the same API as useState
|
|
const newValue = value instanceof Function ? value(readValue()) : value;
|
|
console.log(newValue);
|
|
|
|
// Save to local storage
|
|
window.localStorage.setItem(key, serializer(newValue));
|
|
|
|
// Save state
|
|
setStoredValue(newValue);
|
|
|
|
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
|
window.dispatchEvent(new StorageEvent("local-storage", { key }));
|
|
} catch (error) {
|
|
console.warn(`Error setting localStorage key “${key}”:`, error);
|
|
}
|
|
});
|
|
|
|
const removeValue = useEventCallback(() => {
|
|
// Prevent build error "window is undefined" but keeps working
|
|
if (IS_SERVER) {
|
|
console.warn(
|
|
`Tried removing localStorage key “${key}” even though environment is not a client`,
|
|
);
|
|
}
|
|
|
|
const defaultValue = initialValue instanceof Function
|
|
? initialValue()
|
|
: initialValue;
|
|
|
|
// Remove the key from local storage
|
|
window.localStorage.removeItem(key);
|
|
|
|
// Save state with default value
|
|
setStoredValue(defaultValue);
|
|
|
|
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
|
window.dispatchEvent(new StorageEvent("local-storage", { key }));
|
|
});
|
|
|
|
useEffect(() => {
|
|
setStoredValue(readValue());
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key]);
|
|
|
|
const handleStorageChange = useCallback(
|
|
(event: StorageEvent | CustomEvent) => {
|
|
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
|
|
return;
|
|
}
|
|
setStoredValue(readValue());
|
|
},
|
|
[key, readValue],
|
|
);
|
|
|
|
// this only works for other documents, not the current one
|
|
useEventListener("storage", handleStorageChange);
|
|
|
|
// this is a custom event, triggered in writeValueToLocalStorage
|
|
// See: useLocalStorage()
|
|
useEventListener("local-storage", handleStorageChange);
|
|
|
|
return [storedValue, setValue, removeValue];
|
|
}
|
|
|
|
// --- HOOK 2: useIndexedDB (New) ---
|
|
|
|
/**
|
|
* Custom hook that uses IndexedDB to persist state across page reloads.
|
|
* It shares the same API as `useLocalStorage`.
|
|
*/
|
|
export function useIndexedDB<T>(
|
|
key: string,
|
|
initialValue: T | (() => T),
|
|
options: BaseStorageOptions<T> = {},
|
|
): [T, Dispatch<SetStateAction<T>>, () => void] {
|
|
// Use a memoized instance of the store
|
|
const store = useMemo(() => new ToyStore(), []);
|
|
|
|
const serializer = useCallback<(value: T) => string>(
|
|
(value) => {
|
|
if (options.serializer) return options.serializer(value);
|
|
return JSON.stringify(value);
|
|
},
|
|
[options.serializer],
|
|
);
|
|
|
|
const deserializer = useCallback<(value: string) => T>(
|
|
(value) => {
|
|
if (options.deserializer) return options.deserializer(value);
|
|
if (value === "undefined") return undefined as unknown as T;
|
|
const defaultValue = initialValue instanceof Function
|
|
? initialValue()
|
|
: initialValue;
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch (error) {
|
|
console.error("Error parsing JSON:", error);
|
|
return defaultValue;
|
|
}
|
|
},
|
|
[options.deserializer, initialValue],
|
|
);
|
|
|
|
const [storedValue, setStoredValue] = useState(() =>
|
|
initialValue instanceof Function ? initialValue() : initialValue
|
|
);
|
|
|
|
const readValue = useCallback(async (): Promise<T> => {
|
|
const initialValueToUse = initialValue instanceof Function
|
|
? initialValue()
|
|
: initialValue;
|
|
if (IS_SERVER) return initialValueToUse;
|
|
try {
|
|
const raw = await store.get<string>(key);
|
|
return raw ? deserializer(raw) : initialValueToUse;
|
|
} catch (error) {
|
|
console.warn(`Error reading IndexedDB key “${key}”:`, error);
|
|
return initialValueToUse;
|
|
}
|
|
}, [initialValue, key, deserializer, store]);
|
|
|
|
// Read value from IndexedDB on mount
|
|
useEffect(() => {
|
|
if (!IS_SERVER) {
|
|
readValue().then(setStoredValue);
|
|
}
|
|
}, [key, readValue]);
|
|
|
|
const setValue: Dispatch<SetStateAction<T>> = useEventCallback((value) => {
|
|
if (IS_SERVER) {
|
|
console.warn(`Tried setting IndexedDB key “${key}” on the server.`);
|
|
return;
|
|
}
|
|
const newValue = value instanceof Function ? value(storedValue) : value;
|
|
setStoredValue(newValue); // Optimistic update
|
|
store.set(key, serializer(newValue))
|
|
.then(() => {
|
|
window.dispatchEvent(new StorageEvent("local-storage", { key }));
|
|
})
|
|
.catch((error) => {
|
|
console.warn(`Error setting IndexedDB key “${key}”:`, error);
|
|
});
|
|
});
|
|
|
|
const removeValue = useEventCallback(() => {
|
|
if (IS_SERVER) {
|
|
console.warn(`Tried removing IndexedDB key “${key}” on the server.`);
|
|
return;
|
|
}
|
|
const defaultValue = initialValue instanceof Function
|
|
? initialValue()
|
|
: initialValue;
|
|
setStoredValue(defaultValue); // Optimistic update
|
|
store.remove(key)
|
|
.then(() => {
|
|
window.dispatchEvent(new StorageEvent("local-storage", { key }));
|
|
})
|
|
.catch((error) => {
|
|
console.warn(`Error removing IndexedDB key “${key}”:`, error);
|
|
});
|
|
});
|
|
|
|
const handleStorageChange = useCallback(
|
|
(event: StorageEvent | CustomEvent) => {
|
|
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
|
|
return;
|
|
}
|
|
readValue().then(setStoredValue);
|
|
},
|
|
[key, readValue],
|
|
);
|
|
|
|
// Custom event listener for in-page synchronization
|
|
useEventListener("local-storage", handleStorageChange);
|
|
|
|
return [storedValue, setValue, removeValue];
|
|
}
|
|
|
|
// --- HOOK 3: useStorage (Wrapper) ---
|
|
|
|
type UseStorageOptions<T> = BaseStorageOptions<T> & {
|
|
storageType?: "localStorage" | "indexedDB";
|
|
};
|
|
|
|
/**
|
|
* Custom hook that uses a specified storage type (localStorage or IndexedDB)
|
|
* to persist state across page reloads.
|
|
*/
|
|
export function useStorage<T>(
|
|
key: string,
|
|
initialValue: T | (() => T),
|
|
options: UseStorageOptions<T> = {},
|
|
): [T, Dispatch<SetStateAction<T>>, () => void] {
|
|
const { storageType = "localStorage", ...restOptions } = options;
|
|
|
|
if (storageType === "indexedDB") {
|
|
return useIndexedDB(key, initialValue, restOptions);
|
|
}
|
|
|
|
return useLocalStorage(key, initialValue, restOptions);
|
|
}
|