Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/add optional serialization to use storage #439

Merged
merged 4 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-experts-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"usehooks-ts": patch
---

Add Date, Set & Map support to use\*Storage (#309 by @AlecsFarias)
5 changes: 5 additions & 0 deletions .changeset/young-shirts-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'usehooks-ts': minor
---

Add serialization support for use-\*-storage hooks
2 changes: 2 additions & 0 deletions packages/usehooks-ts/src/useLocalStorage/useLocalStorage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ Persist the state with local storage so that it remains after a page refresh. Th
This hook is used in the same way as useState except that you must pass the storage key in the 1st parameter.
If the window object is not present (as in SSR), `useLocalStorage()` will return the default value.

You can also pass an optional third parameter to use a custom serializer/deserializer.

**Side notes:**

- If you really want to create a dark theme switch, see [useDarkMode()](/react-hook/use-dark-mode).
Expand Down
97 changes: 97 additions & 0 deletions packages/usehooks-ts/src/useLocalStorage/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ describe('useLocalStorage()', () => {
expect(result.current[0]).toEqual([1, 2])
})

test('Initial state is a Map', () => {
const { result } = renderHook(() =>
useLocalStorage('map', new Map([['a', 1]])),
)

expect(result.current[0]).toEqual(new Map([['a', 1]]))
})

test('Initial state is a Set', () => {
const { result } = renderHook(() => useLocalStorage('set', new Set([1, 2])))

expect(result.current[0]).toEqual(new Set([1, 2]))
})

test('Initial state is a Date', () => {
const { result } = renderHook(() =>
useLocalStorage('date', new Date(2020, 1, 1)),
)

expect(result.current[0]).toEqual(new Date(2020, 1, 1))
})

test('Update the state', () => {
const { result } = renderHook(() => useLocalStorage('key', 'value'))

Expand Down Expand Up @@ -144,6 +166,31 @@ describe('useLocalStorage()', () => {
expect(C.current[0]).toBe('initial')
})

test('[Event] Updating one hook does not update others with a different key', () => {
let renderCount = 0
const { result: A } = renderHook(() => {
renderCount++
return useLocalStorage('key1', {})
})
const { result: B } = renderHook(() => useLocalStorage('key2', 'initial'))

expect(renderCount).toBe(1)

act(() => {
const setStateA = A.current[1]
setStateA({ a: 1 })
})

expect(renderCount).toBe(2)

act(() => {
const setStateB = B.current[1]
setStateB('edited')
})

expect(renderCount).toBe(2)
})

test('setValue is referentially stable', () => {
const { result } = renderHook(() => useLocalStorage('count', 1))

Expand All @@ -159,4 +206,54 @@ describe('useLocalStorage()', () => {

expect(result.current[1] === originalCallback).toBe(true)
})

test('should use default JSON.stringify and JSON.parse when serializer/deserializer not provided', () => {
const { result } = renderHook(() => useLocalStorage('key', 'initialValue'))

act(() => {
result.current[1]('newValue')
})

expect(localStorage.getItem('key')).toBe(JSON.stringify('newValue'))
})

test('should use custom serializer and deserializer when provided', () => {
const serializer = (value: string) => value.toUpperCase()
const deserializer = (value: string) => value.toLowerCase()

const { result } = renderHook(() =>
useLocalStorage('key', 'initialValue', { serializer, deserializer }),
)

act(() => {
result.current[1]('NewValue')
})

expect(localStorage.getItem('key')).toBe('NEWVALUE')
})

test('should handle undefined values with custom deserializer', () => {
const serializer = (value: number | undefined) => String(value)
const deserializer = (value: string) =>
value === 'undefined' ? undefined : Number(value)

const { result } = renderHook(() =>
useLocalStorage<number | undefined>('key', 0, {
serializer,
deserializer,
}),
)

act(() => {
result.current[1](undefined)
})

expect(localStorage.getItem('key')).toBe('undefined')

act(() => {
result.current[1](42)
})

expect(localStorage.getItem('key')).toBe('42')
})
})
88 changes: 67 additions & 21 deletions packages/usehooks-ts/src/useLocalStorage/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,90 @@ declare global {
}
}

interface Options<T> {
serializer?: (value: T) => string
deserializer?: (value: string) => T
}

type SetValue<T> = Dispatch<SetStateAction<T>>

const IS_SERVER = typeof window === 'undefined'

export function useLocalStorage<T>(
key: string,
initialValue: T,
initialValue: T | (() => T),
options: Options<T> = {},
): [T, SetValue<T>] {
// Pass initial value to support hydration server-client
const [storedValue, setStoredValue] = useState<T>(initialValue)

const serializer = useCallback<(value: T) => string>(
value => {
if (options.serializer) {
return options.serializer(value)
}

if (value instanceof Map) {
return JSON.stringify(Object.fromEntries(value))
}

if (value instanceof Set) {
return JSON.stringify(Array.from(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 parsed = JSON.parse(value)

if (initialValue instanceof Set) {
return new Set(parsed)
}

if (initialValue instanceof Map) {
return new Map(Object.entries(parsed))
}

if (initialValue instanceof Date) {
return new Date(parsed)
}

return parsed
},
[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 keeps working
if (IS_SERVER) {
return initialValue
return initialValueToUse
}

try {
const item = window.localStorage.getItem(key)
return item ? (parseJSON(item) as T) : initialValue
const raw = window.localStorage.getItem(key)
return raw ? deserializer(raw) : initialValueToUse
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error)
return initialValue
return initialValueToUse
}
}, [initialValue, key])

// State to store our value
// Pass initial value to support hydration server-client
const [storedValue, setStoredValue] = useState<T>(initialValue)
}, [initialValue, key, deserializer])

// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
Expand All @@ -58,7 +114,7 @@ export function useLocalStorage<T>(
const newValue = value instanceof Function ? value(readValue()) : value

// Save to local storage
window.localStorage.setItem(key, JSON.stringify(newValue))
window.localStorage.setItem(key, serializer(newValue))

// Save state
setStoredValue(newValue)
Expand Down Expand Up @@ -94,13 +150,3 @@ export function useLocalStorage<T>(

return [storedValue, setValue]
}

// A wrapper for "JSON.parse()"" to support "undefined" value
function parseJSON<T>(value: string | null): T | undefined {
try {
return value === 'undefined' ? undefined : JSON.parse(value ?? '')
} catch {
console.warn('parsing error on', { value })
return undefined
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,31 @@ type Value<T> = T | null

const IS_SERVER = typeof window === 'undefined'

export function useReadLocalStorage<T>(key: string): Value<T> {
interface Options<T> {
deserializer?: (value: string) => T
}

export function useReadLocalStorage<T>(
key: string,
options: Options<T> = {},
): Value<T> {
// Pass null as initial value to support hydration server-client
const [storedValue, setStoredValue] = useState<Value<T>>(null)

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
}
return JSON.parse(value)
},
[options],
)

// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): Value<T> => {
Expand All @@ -16,17 +40,13 @@ export function useReadLocalStorage<T>(key: string): Value<T> {
}

try {
const item = window.localStorage.getItem(key)
return item ? (JSON.parse(item) as T) : null
const raw = window.localStorage.getItem(key)
return raw ? deserializer(raw) : null
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error)
return null
}
}, [key])

// State to store our value
// Pass null as initial value to support hydration server-client
const [storedValue, setStoredValue] = useState<Value<T>>(null)
}, [key, deserializer])

// Listen if localStorage changes
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
Persist the state with session storage so that it remains after a page refresh. This can be useful to record session information. This hook is used in the same way as useState except that you must pass the storage key in the 1st parameter. If the window object is not present (as in SSR), `useSessionStorage()` will return the default value.

You can also pass an optional third parameter to use a custom serializer/deserializer.

Related hooks:

- [`useLocalStorage()`](/react-hook/use-local-storage)
Loading