From a88beb92dae39c6ac237db7c4c2a62a339737078 Mon Sep 17 00:00:00 2001 From: Mahmood Bagheri Date: Sun, 19 Jun 2022 23:36:12 +0430 Subject: [PATCH 1/4] feat: add useEventCallback hook --- lib/src/useEventCallback/index.ts | 1 + lib/src/useEventCallback/useEventCallback.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 lib/src/useEventCallback/index.ts create mode 100644 lib/src/useEventCallback/useEventCallback.ts diff --git a/lib/src/useEventCallback/index.ts b/lib/src/useEventCallback/index.ts new file mode 100644 index 00000000..943b09e8 --- /dev/null +++ b/lib/src/useEventCallback/index.ts @@ -0,0 +1 @@ +export { default as useEventCallback } from './useEventCallback' diff --git a/lib/src/useEventCallback/useEventCallback.ts b/lib/src/useEventCallback/useEventCallback.ts new file mode 100644 index 00000000..107a918f --- /dev/null +++ b/lib/src/useEventCallback/useEventCallback.ts @@ -0,0 +1,17 @@ +import { useCallback, useRef } from 'react' + +import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect' + +export default function useEventCallback( + fn: (...args: Args) => R, +) { + const ref = useRef(() => { + throw new Error('Cannot call an event handler while rendering.') + }) + + useIsomorphicLayoutEffect(() => { + ref.current = fn + }, [fn]) + + return useCallback((...args: Args) => ref.current(...args), [ref]) +} From 9c33dc860eeb67ebbf9f4e09a10c72c69fbe449b Mon Sep 17 00:00:00 2001 From: Mahmood Bagheri Date: Sun, 19 Jun 2022 23:38:26 +0430 Subject: [PATCH 2/4] feat: add useSessionStorage hook --- lib/src/useSessionStorage/index.ts | 2 + .../useSessionStorage.test.ts | 131 ++++++++++++++++++ .../useSessionStorage/useSessionStorage.ts | 105 ++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 lib/src/useSessionStorage/index.ts create mode 100644 lib/src/useSessionStorage/useSessionStorage.test.ts create mode 100644 lib/src/useSessionStorage/useSessionStorage.ts diff --git a/lib/src/useSessionStorage/index.ts b/lib/src/useSessionStorage/index.ts new file mode 100644 index 00000000..4e47da51 --- /dev/null +++ b/lib/src/useSessionStorage/index.ts @@ -0,0 +1,2 @@ +export { default as useSessionStorage } from './useSessionStorage' +export * from './useSessionStorage' diff --git a/lib/src/useSessionStorage/useSessionStorage.test.ts b/lib/src/useSessionStorage/useSessionStorage.test.ts new file mode 100644 index 00000000..fbecde5c --- /dev/null +++ b/lib/src/useSessionStorage/useSessionStorage.test.ts @@ -0,0 +1,131 @@ +import { act, renderHook } from '@testing-library/react-hooks/dom' + +import useSessionStorage from './useSessionStorage' + +class SessionStorageMock { + store: Record = {} + + 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('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) + }) +}) diff --git a/lib/src/useSessionStorage/useSessionStorage.ts b/lib/src/useSessionStorage/useSessionStorage.ts new file mode 100644 index 00000000..a7ec42cc --- /dev/null +++ b/lib/src/useSessionStorage/useSessionStorage.ts @@ -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 = Dispatch> + +function useSessionStorage(key: string, initialValue: T): [T, SetValue] { + // 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(readValue) + + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to sessionStorage. + const setValue: SetValue = 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(value: string | null): T | undefined { + try { + return value === 'undefined' ? undefined : JSON.parse(value ?? '') + } catch { + console.log('parsing error on', { value }) + return undefined + } +} From 8e6c0fa0c6258de49ec81fce1f37d3ea36d5b4e0 Mon Sep 17 00:00:00 2001 From: Mahmood Bagheri Date: Sun, 19 Jun 2022 23:39:07 +0430 Subject: [PATCH 3/4] refactor: use useEventCallback in useLocalStorage --- lib/src/index.ts | 1 + lib/src/useLocalStorage/useLocalStorage.ts | 17 +++++------------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/lib/src/index.ts b/lib/src/index.ts index 6cd90ae2..ed4bf4df 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -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' diff --git a/lib/src/useLocalStorage/useLocalStorage.ts b/lib/src/useLocalStorage/useLocalStorage.ts index 30110cca..7b230766 100644 --- a/lib/src/useLocalStorage/useLocalStorage.ts +++ b/lib/src/useLocalStorage/useLocalStorage.ts @@ -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' @@ -40,9 +40,9 @@ function useLocalStorage(key: string, initialValue: T): [T, SetValue] { // Pass initial state function to useState so logic is only executed once const [storedValue, setStoredValue] = useState(readValue) - const setValueRef = useRef>() - - setValueRef.current = value => { + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue: SetValue = useEventCallback(value => { // Prevent build error "window is undefined" but keeps working if (typeof window == 'undefined') { console.warn( @@ -65,14 +65,7 @@ function useLocalStorage(key: string, initialValue: T): [T, SetValue] { } 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 = useCallback( - value => setValueRef.current?.(value), - [], - ) + }) useEffect(() => { setStoredValue(readValue()) From dae0770cbd3e2f6684a8449f5046adf618ab0e60 Mon Sep 17 00:00:00 2001 From: Julien Date: Mon, 20 Jun 2022 16:24:11 +0200 Subject: [PATCH 4/4] chore: add demo and content for usesSessionStorage --- README.md | 3 ++- lib/README.md | 1 + scripts/updateReadme.ts | 20 ++++++++++++------- site/src/hooks-doc/index.ts | 1 + .../useLocalStorage/useLocalStorage.mdx | 6 +++++- site/src/hooks-doc/useSessionStorage/index.ts | 1 + .../useSessionStorage.demo.tsx | 15 ++++++++++++++ .../useSessionStorage/useSessionStorage.mdx | 10 ++++++++++ 8 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 site/src/hooks-doc/useSessionStorage/index.ts create mode 100644 site/src/hooks-doc/useSessionStorage/useSessionStorage.demo.tsx create mode 100644 site/src/hooks-doc/useSessionStorage/useSessionStorage.mdx diff --git a/README.md b/README.md index 803f6a1f..bf32f6a7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/lib/README.md b/lib/README.md index 14a30195..379c0ffb 100644 --- a/lib/README.md +++ b/lib/README.md @@ -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) diff --git a/scripts/updateReadme.ts b/scripts/updateReadme.ts index 0ccd0fdc..5a44fa66 100644 --- a/scripts/updateReadme.ts +++ b/scripts/updateReadme.ts @@ -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 { diff --git a/site/src/hooks-doc/index.ts b/site/src/hooks-doc/index.ts index 8a95e89c..91b8c627 100644 --- a/site/src/hooks-doc/index.ts +++ b/site/src/hooks-doc/index.ts @@ -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' diff --git a/site/src/hooks-doc/useLocalStorage/useLocalStorage.mdx b/site/src/hooks-doc/useLocalStorage/useLocalStorage.mdx index 639ca4ea..bc460320 100644 --- a/site/src/hooks-doc/useLocalStorage/useLocalStorage.mdx +++ b/site/src/hooks-doc/useLocalStorage/useLocalStorage.mdx @@ -3,7 +3,7 @@ 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. @@ -11,3 +11,7 @@ If the window object is not present (as in SSR), `useLocalStorage()` will return - 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) diff --git a/site/src/hooks-doc/useSessionStorage/index.ts b/site/src/hooks-doc/useSessionStorage/index.ts new file mode 100644 index 00000000..829ae241 --- /dev/null +++ b/site/src/hooks-doc/useSessionStorage/index.ts @@ -0,0 +1 @@ +export * from './useSessionStorage.demo' diff --git a/site/src/hooks-doc/useSessionStorage/useSessionStorage.demo.tsx b/site/src/hooks-doc/useSessionStorage/useSessionStorage.demo.tsx new file mode 100644 index 00000000..e3fc76ec --- /dev/null +++ b/site/src/hooks-doc/useSessionStorage/useSessionStorage.demo.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +import { useSessionStorage } from 'usehooks-ts' + +export default function Component() { + const [value, setValue] = useSessionStorage('test-key', 0) + + return ( +
+

Count: {value}

+ + +
+ ) +} diff --git a/site/src/hooks-doc/useSessionStorage/useSessionStorage.mdx b/site/src/hooks-doc/useSessionStorage/useSessionStorage.mdx new file mode 100644 index 00000000..5492c269 --- /dev/null +++ b/site/src/hooks-doc/useSessionStorage/useSessionStorage.mdx @@ -0,0 +1,10 @@ +--- +title: useSessionStorage +date: '2022-06-20' +--- + +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. + +Related hooks: + +- [`useLocalStorage()`](/react-hook/use-local-storage)