storytellurx-v1/app/hooks/use-storage.ts

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);
}