1+ /* eslint-disable react-hooks/exhaustive-deps */
12import { SetStateAction , useCallback , useRef , useSyncExternalStore } from 'react' ;
23
34import { safeLocalStorage , Storage } from './storage.ts' ;
45
5- type ToPrimitive < T > = T extends string ? string : T extends number ? number : T extends boolean ? boolean : never ;
66type ToObject < T > = T extends unknown [ ] | Record < string , unknown > ? T : never ;
77
8- export type Serializable < T > = T extends string | number | boolean ? ToPrimitive < T > : ToObject < T > ;
8+ export type Serializable < T > = T extends string | number | boolean ? T : ToObject < T > ;
99
1010type StorageStateOptions < T > = {
1111 storage ?: Storage ;
12- defaultValue ?: Serializable < T > ;
12+ defaultValue ?: T ;
1313} ;
1414
1515type StorageStateOptionsWithDefaultValue < T > = StorageStateOptions < T > & {
16- defaultValue : Serializable < T > ;
16+ defaultValue : T ;
1717} ;
1818
19+ type StorageStateOptionsWithSerializer < T > = StorageStateOptions < T > & {
20+ serializer : ( value : Serializable < T > ) => string ;
21+ deserializer : ( value : string ) => Serializable < T > ;
22+ } ;
23+
24+ type SerializableGuard < T extends readonly any [ ] > = T [ 0 ] extends any
25+ ? T
26+ : T [ 0 ] extends never
27+ ? 'Received a non-serializable value'
28+ : T ;
29+
1930const listeners = new Set < ( ) => void > ( ) ;
2031
2132const emitListeners = ( ) => {
2233 listeners . forEach ( listener => listener ( ) ) ;
2334} ;
2435
36+ function isPlainObject ( value : unknown ) : value is Record < PropertyKey , any > {
37+ if ( typeof value !== 'object' ) {
38+ return false ;
39+ }
40+
41+ const proto = Object . getPrototypeOf ( value ) as typeof Object . prototype | null ;
42+
43+ const hasObjectPrototype = proto === Object . prototype ;
44+
45+ if ( ! hasObjectPrototype ) {
46+ return false ;
47+ }
48+
49+ return Object . prototype . toString . call ( value ) === '[object Object]' ;
50+ }
51+
52+ const ensureSerializable = < T extends readonly any [ ] > ( value : T ) : SerializableGuard < T > => {
53+ if (
54+ value [ 0 ] != null &&
55+ ! [ 'string' , 'number' , 'boolean' ] . includes ( typeof value [ 0 ] ) &&
56+ ! ( isPlainObject ( value [ 0 ] ) || Array . isArray ( value [ 0 ] ) )
57+ ) {
58+ throw new Error ( 'Received a non-serializable value' ) ;
59+ }
60+
61+ return value as SerializableGuard < T > ;
62+ } ;
63+
2564/**
2665 * @description
2766 * A React hook that functions like `useState` but persists the state value in browser storage.
@@ -31,11 +70,13 @@ const emitListeners = () => {
3170 * @param {Object } [options] - Configuration options for storage behavior.
3271 * @param {Storage } [options.storage=localStorage] - The storage type (`localStorage` or `sessionStorage`). Defaults to `localStorage`.
3372 * @param {T } [options.defaultValue] - The initial value if no existing value is found.
73+ * @param {Function } [options.serializer] - A function to serialize the state value to a string.
74+ * @param {Function } [options.deserializer] - A function to deserialize the state value from a string.
3475 *
35- * @returns {readonly [state: Serializable<T> | undefined, setState: (value: SetStateAction<Serializable<T> | undefined>) => void] } A tuple:
76+ * @returns {readonly [state: Serializable<T> | undefined, setState: (value: SetStateAction<Serializable<T> | undefined>) => void, refreshState: () => void ] } A tuple:
3677 * - state `Serializable<T> | undefined` - The current state value retrieved from storage;
3778 * - setState `(value: SetStateAction<Serializable<T> | undefined>) => void` - A function to update and persist the state;
38- *
79+ * - refreshState `() => void` - A function to refresh the state from storage;
3980 * @example
4081 * // Counter with persistent state
4182 * import { useStorageState } from 'react-simplikit';
@@ -50,35 +91,53 @@ const emitListeners = () => {
5091 */
5192export function useStorageState < T > (
5293 key : string
53- ) : readonly [ Serializable < T > | undefined , ( value : SetStateAction < Serializable < T > | undefined > ) => void ] ;
94+ ) : SerializableGuard <
95+ readonly [ Serializable < T > | undefined , ( value : SetStateAction < Serializable < T > | undefined > ) => void , ( ) => void ]
96+ > ;
5497export function useStorageState < T > (
5598 key : string ,
56- { storage , defaultValue } : StorageStateOptionsWithDefaultValue < T >
57- ) : readonly [ Serializable < T > , ( value : SetStateAction < Serializable < T > > ) => void ] ;
99+ options : StorageStateOptionsWithDefaultValue < T >
100+ ) : SerializableGuard < readonly [ Serializable < T > , ( value : SetStateAction < Serializable < T > > ) => void , ( ) => void ] > ;
58101export function useStorageState < T > (
59102 key : string ,
60- { storage, defaultValue } : StorageStateOptions < T >
61- ) : readonly [ Serializable < T > | undefined , ( value : SetStateAction < Serializable < T > | undefined > ) => void ] ;
103+ options : StorageStateOptions < T >
104+ ) : SerializableGuard <
105+ readonly [ Serializable < T > | undefined , ( value : SetStateAction < Serializable < T > | undefined > ) => void , ( ) => void ]
106+ > ;
62107export function useStorageState < T > (
63108 key : string ,
64- { storage = safeLocalStorage , defaultValue } : StorageStateOptions < T > = { }
65- ) : readonly [ Serializable < T > | undefined , ( value : SetStateAction < Serializable < T > | undefined > ) => void ] {
109+ options : StorageStateOptionsWithSerializer < T >
110+ ) : SerializableGuard <
111+ readonly [ Serializable < T > | undefined , ( value : SetStateAction < Serializable < T > | undefined > ) => void , ( ) => void ]
112+ > ;
113+ export function useStorageState < T > (
114+ key : string ,
115+ {
116+ storage = safeLocalStorage ,
117+ defaultValue,
118+ ...options
119+ } : StorageStateOptions < T > | StorageStateOptionsWithSerializer < T > = { }
120+ ) : SerializableGuard <
121+ readonly [ Serializable < T > | undefined , ( value : SetStateAction < Serializable < T > | undefined > ) => void , ( ) => void ]
122+ > {
123+ const serializedDefaultValue = defaultValue as Serializable < T > ;
66124 const cache = useRef < {
67125 data : string | null ;
68126 parsed : Serializable < T > | undefined ;
69127 } > ( {
70128 data : null ,
71- parsed : defaultValue ,
129+ parsed : serializedDefaultValue ,
72130 } ) ;
73131
74132 const getSnapshot = useCallback ( ( ) => {
133+ const deserializer = 'deserializer' in options ? options . deserializer : JSON . parse ;
75134 const data = storage . get ( key ) ;
76135
77136 if ( data !== cache . current . data ) {
78137 try {
79- cache . current . parsed = data != null ? JSON . parse ( data ) : defaultValue ;
138+ cache . current . parsed = data != null ? deserializer ( data ) : defaultValue ;
80139 } catch {
81- cache . current . parsed = defaultValue ;
140+ cache . current . parsed = serializedDefaultValue ;
82141 }
83142 cache . current . data = data ;
84143 }
@@ -104,22 +163,28 @@ export function useStorageState<T>(
104163 } ;
105164 } ,
106165 ( ) => getSnapshot ( ) ,
107- ( ) => defaultValue
166+ ( ) => serializedDefaultValue
108167 ) ;
109168
110169 const setStorageState = useCallback (
111170 ( value : SetStateAction < Serializable < T > | undefined > ) => {
171+ const serializer = 'serializer' in options ? options . serializer : JSON . stringify ;
172+
112173 const nextValue = typeof value === 'function' ? value ( getSnapshot ( ) ) : value ;
113174
114175 if ( nextValue == null ) {
115176 storage . remove ( key ) ;
116177 } else {
117- storage . set ( key , JSON . stringify ( nextValue ) ) ;
178+ storage . set ( key , serializer ( nextValue ) ) ;
118179 }
119180 emitListeners ( ) ;
120181 } ,
121182 [ getSnapshot , key , storage ]
122183 ) ;
123184
124- return [ storageState , setStorageState ] as const ;
185+ const refreshStorageState = useCallback ( ( ) => {
186+ setStorageState ( getSnapshot ( ) ) ;
187+ } , [ storage , getSnapshot , setStorageState ] ) ;
188+
189+ return ensureSerializable ( [ storageState , setStorageState , refreshStorageState ] as const ) ;
125190}
0 commit comments