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

add useSessionStorage hook #171

Merged
merged 4 commits into from
Jun 20, 2022
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ If you'd like to submit new post ideas, improve existing posts, or change anythi
- [`useReadLocalStorage()`](https://usehooks-ts.com/react-hook/use-read-local-storage)
- [`useScreen()`](https://usehooks-ts.com/react-hook/use-screen)
- [`useScript()`](https://usehooks-ts.com/react-hook/use-script)
- [`useSessionStorage()`](https://usehooks-ts.com/react-hook/use-session-storage)
- [`useSsr()`](https://usehooks-ts.com/react-hook/use-ssr)
- [`useStep()`](https://usehooks-ts.com/react-hook/use-step)
- [`useTernaryDarkMode()`](https://usehooks-ts.com/react-hook/use-ternary-dark-mode)
Expand Down Expand Up @@ -292,7 +293,7 @@ This project follows the [all-contributors](https://github.com/all-contributors/

## 🚗 Roadmap

- [ ] Add new hooks (web3 hooks are welcome!)
- [ ] Add more hooks
- [ ] Develop automated tests for all hooks

## 📝 License
Expand Down
1 change: 1 addition & 0 deletions lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ npm i usehooks-ts
- [`useReadLocalStorage()`](https://usehooks-ts.com/react-hook/use-read-local-storage)
- [`useScreen()`](https://usehooks-ts.com/react-hook/use-screen)
- [`useScript()`](https://usehooks-ts.com/react-hook/use-script)
- [`useSessionStorage()`](https://usehooks-ts.com/react-hook/use-session-storage)
- [`useSsr()`](https://usehooks-ts.com/react-hook/use-ssr)
- [`useStep()`](https://usehooks-ts.com/react-hook/use-step)
- [`useTernaryDarkMode()`](https://usehooks-ts.com/react-hook/use-ternary-dark-mode)
Expand Down
1 change: 1 addition & 0 deletions lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from './useOnClickOutside'
export * from './useReadLocalStorage'
export * from './useScreen'
export * from './useScript'
export * from './useSessionStorage'
export * from './useSsr'
export * from './useStep'
export * from './useTernaryDarkMode'
Expand Down
1 change: 1 addition & 0 deletions lib/src/useEventCallback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useEventCallback } from './useEventCallback'
17 changes: 17 additions & 0 deletions lib/src/useEventCallback/useEventCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useCallback, useRef } from 'react'

import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'

export default function useEventCallback<Args extends unknown[], R>(
fn: (...args: Args) => R,
) {
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])
}
17 changes: 5 additions & 12 deletions lib/src/useLocalStorage/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import {
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react'

import { useEventCallback } from '../useEventCallback'
// See: https://usehooks-ts.com/react-hook/use-event-listener
import { useEventListener } from '../useEventListener'

Expand Down Expand Up @@ -40,9 +40,9 @@ function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>] {
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(readValue)

const setValueRef = useRef<SetValue<T>>()

setValueRef.current = value => {
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = useEventCallback(value => {
// Prevent build error "window is undefined" but keeps working
if (typeof window == 'undefined') {
console.warn(
Expand All @@ -65,14 +65,7 @@ function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>] {
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error)
}
}

// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = useCallback(
value => setValueRef.current?.(value),
[],
)
})

useEffect(() => {
setStoredValue(readValue())
Expand Down
2 changes: 2 additions & 0 deletions lib/src/useSessionStorage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as useSessionStorage } from './useSessionStorage'
export * from './useSessionStorage'
131 changes: 131 additions & 0 deletions lib/src/useSessionStorage/useSessionStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { act, renderHook } from '@testing-library/react-hooks/dom'

import useSessionStorage from './useSessionStorage'

class SessionStorageMock {
store: Record<string, unknown> = {}

clear() {
this.store = {}
}

getItem(key: string) {
return this.store[key] || null
}

setItem(key: string, value: unknown) {
this.store[key] = value + ''
}

removeItem(key: string) {
delete this.store[key]
}
}

Object.defineProperty(window, 'sessionStorage', {
value: new SessionStorageMock(),
})

describe('useSessionStorage()', () => {
beforeEach(() => {
window.sessionStorage.clear()
})

afterEach(() => {
jest.clearAllMocks()
})

test('initial state is in the returned state', () => {
const { result } = renderHook(() => useSessionStorage('key', 'value'))

expect(result.current[0]).toBe('value')
})

test('Initial state is a callback function', () => {
const { result } = renderHook(() => useSessionStorage('key', () => 'value'))

expect(result.current[0]).toBe('value')
})

test('Initial state is an array', () => {
const { result } = renderHook(() => useSessionStorage('digits', [1, 2]))

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

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

act(() => {
const setState = result.current[1]
setState('edited')
})

expect(result.current[0]).toBe('edited')
})

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

act(() => {
const setState = result.current[1]
setState('edited')
})

expect(window.sessionStorage.getItem('key')).toBe(JSON.stringify('edited'))
})

test('Update the state with undefined', () => {
const { result } = renderHook(() =>
useSessionStorage<string | undefined>('keytest', 'value'),
)

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

expect(result.current[0]).toBeUndefined()
})

test('Update the state with a callback function', () => {
const { result } = renderHook(() => useSessionStorage('count', 2))

act(() => {
const setState = result.current[1]
setState(prev => prev + 1)
})

expect(result.current[0]).toBe(3)
expect(window.sessionStorage.getItem('count')).toEqual('3')
})

test('[Event] Update one hook updates the others', () => {
const initialValues: [string, unknown] = ['key', 'initial']
const { result: A } = renderHook(() => useSessionStorage(...initialValues))
const { result: B } = renderHook(() => useSessionStorage(...initialValues))

act(() => {
const setState = A.current[1]
setState('edited')
})

expect(B.current[0]).toBe('edited')
})

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

// Store a reference to the original setValue
const originalCallback = result.current[1]

// Now invoke a state update, if setValue is not referentially stable then this will cause the originalCallback
// reference to not be equal to the new setValue function
act(() => {
const setState = result.current[1]
setState(prev => prev + 1)
})

expect(result.current[1] === originalCallback).toBe(true)
})
})
105 changes: 105 additions & 0 deletions lib/src/useSessionStorage/useSessionStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react'

import { useEventCallback } from '../useEventCallback'
// See: https://usehooks-ts.com/react-hook/use-event-listener
import { useEventListener } from '../useEventListener'

declare global {
interface WindowEventMap {
'session-storage': CustomEvent
}
}

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

function useSessionStorage<T>(key: string, initialValue: T): [T, SetValue<T>] {
// Get from session storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
// Prevent build error "window is undefined" but keep keep working
if (typeof window === 'undefined') {
return initialValue
}

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

// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(readValue)

// Return a wrapped version of useState's setter function that ...
// ... persists the new value to sessionStorage.
const setValue: SetValue<T> = useEventCallback(value => {
// Prevent build error "window is undefined" but keeps working
if (typeof window == 'undefined') {
console.warn(
`Tried setting sessionStorage 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(storedValue) : value

// Save to session storage
window.sessionStorage.setItem(key, JSON.stringify(newValue))

// Save state
setStoredValue(newValue)

// We dispatch a custom event so every useSessionStorage hook are notified
window.dispatchEvent(new Event('session-storage'))
} catch (error) {
console.warn(`Error setting sessionStorage key “${key}”:`, error)
}
})

useEffect(() => {
setStoredValue(readValue())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

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 writeValueTosessionStorage
// See: useSessionStorage()
useEventListener('session-storage', handleStorageChange)

return [storedValue, setValue]
}

export default useSessionStorage

// 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.log('parsing error on', { value })
return undefined
}
}
20 changes: 13 additions & 7 deletions scripts/updateReadme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,26 @@ interface MarkdownLine {
markdownLine: string
}

function formatHook(name: string, demos: string[]): MarkdownLine {
function formatHook(name: string, demos: string[]): MarkdownLine | null {
const hasDemo = demos.includes(name)
if (!hasDemo) console.warn(`${name} haven't demo yet!`)
if (!hasDemo) {
console.warn(`${name} haven't demo yet!`)
return null
}

return {
name,
markdownLine: hasDemo
? `- [\`${name}()\`](${createUrl(name)})\n`
: `- ${name}\n`,
markdownLine: `- [\`${name}()\`](${createUrl(name)})\n`,
}
}

function createMarkdownList(hooks: MarkdownLine[]): string {
return hooks.reduce((acc, hook) => acc + hook.markdownLine, '')
function createMarkdownList(hooks: (MarkdownLine | null)[]): string {
return hooks.reduce((acc, hook) => {
if (hook) {
return acc + hook.markdownLine
}
return acc
}, '')
}

function insertIn(markdown: string, file: fs.PathOrFileDescriptor): void {
Expand Down
1 change: 1 addition & 0 deletions site/src/hooks-doc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export * from './useOnClickOutside'
export * from './useReadLocalStorage'
export * from './useScreen'
export * from './useScript'
export * from './useSessionStorage'
export * from './useSsr'
export * from './useStep'
export * from './useTernaryDarkMode'
Expand Down
6 changes: 5 additions & 1 deletion site/src/hooks-doc/useLocalStorage/useLocalStorage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ title: useLocalStorage
date: '2020-04-20'
---

Persist the state with local storage so that it remains after a page refresh. This can be useful for a dark theme or to record session information.
Persist the state with local storage so that it remains after a page refresh. This can be useful for a dark theme.
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.

**Side notes:**

- If you really want to create a dark theme switch, see [useDarkMode()](/react-hook/use-dark-mode).
- If you just want read value from local storage, see [useReadLocalStorage()](/react-hook/use-read-local-storage).

Related hooks:

- [`useSessionStorage()`](/react-hook/use-session-storage)
1 change: 1 addition & 0 deletions site/src/hooks-doc/useSessionStorage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useSessionStorage.demo'
Loading