useStorageState
EditSynchronizes generic React state with local or session storage across same- and cross-document updates.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react";
export type StorageKind = "local" | "session";
export type StorageCodec<Value> = { parse: (this: void, storedValue: string) => Value; stringify: (this: void, value: Value) => string;};
export type NonCallable<Value> = Value extends ( ...args: never[]) => unknown ? never : Value;
export type StorageInitialValue<Value> = | NonCallable<Value> | ((this: void) => Value);
export type StorageStateAction<Value> = | NonCallable<Value> | ((this: void, previousValue: Value) => Value);
export type StorageStateSetter<Value> = ( this: void, nextValue: StorageStateAction<Value>,) => void;
export type UseStorageStateOptions<Value> = { storage?: StorageKind; codec?: StorageCodec<Value>; initializeWithValue?: boolean; onError?: (this: void, error: unknown) => void;};
export type UseStorageStateReturn<Value> = { value: Value; setValue: StorageStateSetter<Value>; remove: () => void; error: unknown | undefined;};
type StorageSnapshot<Value> = { value: Value; error: unknown | undefined; notifyError: boolean;};
type StorageStateEventDetail = { storage: StorageKind; key: string; storedValue: string | null;};
const storageStateEvent = "lazykit:storage-state";
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
const getStorage = (kind: StorageKind): Storage => kind === "local" ? window.localStorage : window.sessionStorage;
const jsonCodec: StorageCodec<unknown> = { parse: (storedValue) => JSON.parse(storedValue) as unknown, stringify: (value) => { const storedValue = JSON.stringify(value); if (storedValue === undefined) { throw new TypeError("value cannot be serialized as JSON"); } return storedValue; },};
const defaultCodec = <Value>(): StorageCodec<Value> => jsonCodec as StorageCodec<Value>;
/** * Synchronizes state with localStorage or sessionStorage. * * The default JSON codec trusts persisted data. Pass a validating codec when * values can be written by code outside your control. */export const useStorageState = <Value>( key: string, initialValue: StorageInitialValue<Value>, options: UseStorageStateOptions<Value> = {},): UseStorageStateReturn<Value> => { const kind = options.storage ?? "local"; const initializeWithValue = options.initializeWithValue ?? true; const codec = options.codec ?? defaultCodec<Value>(); const fallback = useMemo( () => typeof initialValue === "function" ? Reflect.apply(initialValue as (this: void) => Value, undefined, []) : initialValue, [key, kind], );
const codecRef = useRef(codec); const onErrorRef = useRef(options.onError);
useIsomorphicLayoutEffect(() => { codecRef.current = codec; onErrorRef.current = options.onError; }, [codec, options.onError]);
const readStoredValue = useCallback( (storedValue?: string | null): StorageSnapshot<Value> => { try { const raw = storedValue === undefined ? getStorage(kind).getItem(key) : storedValue; if (raw === null) { return { value: fallback, error: undefined, notifyError: false }; } return { value: Reflect.apply(codec.parse, undefined, [raw]), error: undefined, notifyError: false, }; } catch (error) { return { value: fallback, error, notifyError: true }; } }, [codec, fallback, key, kind], );
const [snapshot, setSnapshot] = useState<StorageSnapshot<Value>>(() => { if (!initializeWithValue || typeof window === "undefined") { return { value: fallback, error: undefined, notifyError: false }; } return readStoredValue(); }); const valueRef = useRef(snapshot.value); const reportError = useCallback((error: unknown) => { const onError = onErrorRef.current; if (onError !== undefined) { Reflect.apply(onError, undefined, [error]); } }, []); const applySnapshot = useCallback( (next: StorageSnapshot<Value>, reportImmediately = false) => { valueRef.current = next.value; if (reportImmediately && next.error !== undefined) { setSnapshot({ ...next, notifyError: false }); reportError(next.error); return; } setSnapshot(next); }, [reportError], );
useEffect(() => { if (snapshot.notifyError && snapshot.error !== undefined) { reportError(snapshot.error); } }, [reportError, snapshot]);
useIsomorphicLayoutEffect(() => { if (typeof window === "undefined") return;
applySnapshot(readStoredValue(), true);
const handleStorage = (event: StorageEvent) => { if (event.key !== null && event.key !== key) return;
try { if (event.storageArea !== null && event.storageArea !== getStorage(kind)) { return; } } catch (error) { applySnapshot( { value: fallback, error, notifyError: true }, true, ); return; }
applySnapshot(readStoredValue(event.newValue), true); };
const handleSameDocumentStorage = (event: Event) => { const detail = (event as CustomEvent<unknown>).detail; if (detail === null || typeof detail !== "object") return; const candidate = detail as Partial<StorageStateEventDetail>; if ( (candidate.storage !== "local" && candidate.storage !== "session") || typeof candidate.key !== "string" || (candidate.storedValue !== null && typeof candidate.storedValue !== "string") ) { return; } if (candidate.storage !== kind || candidate.key !== key) return; applySnapshot(readStoredValue(candidate.storedValue), true); };
window.addEventListener("storage", handleStorage); window.addEventListener(storageStateEvent, handleSameDocumentStorage);
return () => { window.removeEventListener("storage", handleStorage); window.removeEventListener(storageStateEvent, handleSameDocumentStorage); }; }, [applySnapshot, fallback, key, kind, readStoredValue]);
const publish = useCallback( (storedValue: string | null) => { window.dispatchEvent( new CustomEvent<StorageStateEventDetail>(storageStateEvent, { detail: { storage: kind, key, storedValue }, }), ); }, [key, kind], );
const setValue = useCallback<StorageStateSetter<Value>>( (nextValue) => { const next = typeof nextValue === "function" ? Reflect.apply( nextValue as (this: void, previousValue: Value) => Value, undefined, [valueRef.current], ) : nextValue; valueRef.current = next;
let storedValue: string; try { storedValue = Reflect.apply(codecRef.current.stringify, undefined, [ next, ]); getStorage(kind).setItem(key, storedValue); applySnapshot({ value: next, error: undefined, notifyError: false, }); publish(storedValue); } catch (error) { applySnapshot({ value: next, error, notifyError: true }, true); } }, [applySnapshot, key, kind, publish], );
const remove = useCallback(() => { valueRef.current = fallback;
try { getStorage(kind).removeItem(key); applySnapshot({ value: fallback, error: undefined, notifyError: false, }); publish(null); } catch (error) { applySnapshot({ value: fallback, error, notifyError: true }, true); } }, [applySnapshot, fallback, key, kind, publish]);
return { value: snapshot.value, setValue, remove, error: snapshot.error, };};Download
Section titled “Download”wget -O src/hooks/useStorageState.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useStorageState.tstemp_dir="$(mktemp -d)" && bunx degit jrTilak/lazykit/registry/react-hooks "$temp_dir" && mv "$temp_dir/useStorageState.ts" src/hooks/useStorageState.ts && rm -r "$temp_dir"Examples
Section titled “Examples”import { useStorageState } from "./useStorageState";
import type { StorageCodec } from "./useStorageState";
type Preference = "light" | "dark";
const preferenceCodec: StorageCodec<Preference> = { parse: (storedValue) => { if (storedValue !== "light" && storedValue !== "dark") { throw new TypeError("Invalid preference"); } return storedValue; }, stringify: (value) => value,};
export const ThemePreference = () => { const preference = useStorageState<Preference>("theme", "light", { codec: preferenceCodec, });
return ( <button type="button" onClick={() => preference.setValue((value) => (value === "light" ? "dark" : "light")) } > Theme: {preference.value} </button> );};key(string) — Storage key to read, write, remove, and subscribe to.initialValue(NonCallable<T> | () => T) — Fallback used for missing, cleared, unavailable, or invalid persisted data. Each key and storage-area pair captures its fallback.options.storage("local" | "session", default:"local") — Backing storage area.options.codec(StorageCodec<T>) —parse(storedValue)andstringify(value)functions. Changing the codec identity rereads the current key.options.initializeWithValue(boolean, default:true) — Reads during browser initialization. The hook always waits until an effect when rendered on the server.options.onError((error: unknown) => void) — Receives read, parse, serialization, write, and removal failures.
Returns
Section titled “Returns”An object containing:
value(T) — Current value.setValue(StorageStateSetter<T>) — Accepts a direct non-callable value or an updater.remove()— Removes the key and restores the initial value.error(unknown | undefined) — Latest storage or codec failure.
Callable values must be returned by the lazy initializer and updater wrappers, such as () => handler. The default codec uses JSON.parse and JSON.stringify and treats parsed data as T. Use a validating codec for data written outside the application. Native storage events synchronize other documents, including Storage.clear().