Skip to content

Commit

Permalink
🐛 Fix: SSR hooks now fallback with default or initial value instead o…
Browse files Browse the repository at this point in the history
…f `undefined` (#472)
  • Loading branch information
juliencrn authored Feb 6, 2024
1 parent de5a18d commit 87ba579
Show file tree
Hide file tree
Showing 19 changed files with 150 additions and 187 deletions.
5 changes: 5 additions & 0 deletions .changeset/flat-radios-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"usehooks-ts": minor
---

Fix SSR hooks by fallback with default or initial value instead of `undefined`
5 changes: 4 additions & 1 deletion packages/usehooks-ts/src/useDarkMode/useDarkMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ describe('useDarkMode()', () => {
})

it('should accept a custom default value', () => {
const { result } = renderHook(() => useDarkMode({ defaultValue: true }))
mockMatchMedia(true)
const { result } = renderHook(() =>
useDarkMode({ defaultValue: true, initializeWithValue: false }),
)

expect(result.current.isDarkMode).toBe(true)

Expand Down
48 changes: 23 additions & 25 deletions packages/usehooks-ts/src/useDarkMode/useDarkMode.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { useRef } from 'react'

import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'
import { useLocalStorage } from '../useLocalStorage'
import { useMediaQuery } from '../useMediaQuery'
import { useUpdateEffect } from '../useUpdateEffect'

const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'
const LOCAL_STORAGE_KEY = 'usehooks-ts-dark-mode'

type DarkModeOptions<InitializeWithValue extends boolean | undefined> = {
type DarkModeOptions = {
defaultValue?: boolean
localStorageKey?: string
initializeWithValue: InitializeWithValue
initializeWithValue?: boolean
}

interface DarkModeOutput<T extends boolean | undefined> {
isDarkMode: T
interface DarkModeOutput {
isDarkMode: boolean
toggle: () => void
enable: () => void
disable: () => void
Expand All @@ -32,37 +34,29 @@ interface DarkModeOutput<T extends boolean | undefined> {
export function useDarkMode(
defaultValue: boolean,
localStorageKey?: string,
): DarkModeOutput<boolean>

// SSR version of useDarkMode.
export function useDarkMode(
options: DarkModeOptions<false>,
): DarkModeOutput<boolean | undefined>

// CSR version of useDarkMode.
export function useDarkMode(
options?: Partial<DarkModeOptions<true>>,
): DarkModeOutput<boolean>

): DarkModeOutput
export function useDarkMode(options?: DarkModeOptions): DarkModeOutput
/**
* Custom hook that returns the current state of the dark mode.
* @param {?boolean | ?DarkModeOptions} [options] - the initial value of the dark mode, default `false`.
* @param {?boolean} [options.defaultValue] - the initial value of the dark mode, default `false`.
* @param {?string} [options.localStorageKey] - the key to use in the local storage, default `'usehooks-ts-dark-mode'`.
* @param {?boolean} [options.initializeWithValue] - if `true` (default), the hook will initialize reading `localStorage`. In SSR, you should set it to `false`, returning `undefined` or the `defaultValue` initially.
* @param {?boolean} [options.initializeWithValue] - if `true` (default), the hook will initialize reading `localStorage`. In SSR, you should set it to `false`, returning the `defaultValue` or `false` initially.
* @param {?string} [localStorageKeyProps] the key to use in the local storage, default `'usehooks-ts-dark-mode'`.
* @returns {DarkModeOutput} An object containing the dark mode's state and its controllers.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-dark-mode)
* @example
* const { isDarkMode, toggle, enable, disable, set } = useDarkMode({ defaultValue: true });
*/
export function useDarkMode(
options?: boolean | Partial<DarkModeOptions<boolean>>,
options?: boolean | DarkModeOptions,
localStorageKeyProps: string = LOCAL_STORAGE_KEY,
): DarkModeOutput<boolean | undefined> {
): DarkModeOutput {
const counter = useRef(0)
counter.current++
// TODO: Refactor this code after the deprecated signature has been removed.
const defaultValue =
typeof options === 'boolean' ? options : options?.defaultValue ?? false
typeof options === 'boolean' ? options : options?.defaultValue
const localStorageKey =
typeof options === 'boolean'
? localStorageKeyProps ?? LOCAL_STORAGE_KEY
Expand All @@ -72,17 +66,21 @@ export function useDarkMode(
? undefined
: options?.initializeWithValue ?? undefined

const isDarkOS = useMediaQuery(COLOR_SCHEME_QUERY)
const isDarkOS = useMediaQuery(COLOR_SCHEME_QUERY, {
initializeWithValue,
defaultValue,
})
const [isDarkMode, setDarkMode] = useLocalStorage<boolean>(
localStorageKey,
defaultValue ?? isDarkOS ?? false,
{ initializeWithValue },
)

// Update darkMode if os prefers changes
useUpdateEffect(() => {
setDarkMode(isDarkOS)
// eslint-disable-next-line react-hooks/exhaustive-deps
useIsomorphicLayoutEffect(() => {
if (isDarkOS !== isDarkMode) {
setDarkMode(isDarkOS)
}
}, [isDarkOS])

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useElementSize } from './useElementSize'

export default function Component() {
const [isVisible, setVisible] = useState(true)
const [squareRef, { width, height }] = useElementSize()
const [squareRef, { width = 0, height = 0 }] = useElementSize()

const toggleVisibility = () => {
setVisible(x => !x)
Expand Down
2 changes: 1 addition & 1 deletion packages/usehooks-ts/src/useElementSize/useElementSize.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
This hook helps you to dynamically recover the width and the height of an HTML element.
Dimensions are updated on load, on mount/un-mount, when resizing the window and when the ref changes.

**Note**: If you use this hook in an SSR context, set the `initializeWithValue` option to `false`.
**Note**: If you use this hook in an SSR context, set the `initializeWithValue` option to `{ width: undefined, height: undefined }` (You can pass it default values when initializing the hook, see example below).
13 changes: 9 additions & 4 deletions packages/usehooks-ts/src/useElementSize/useElementSize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,16 @@ describe('useElementSize()', () => {

it('should initialize', () => {
const { result } = setupHook()
const [setRef, size] = result.current

expect(typeof size.height).toBe('number')
expect(typeof size.width).toBe('number')
expect(setRef).toBeInstanceOf(Function)
act(() => {
result.current.ref(dom)
resizeElement(dom, 'width', 1920)
resizeElement(dom, 'height', 1080)
})

expect(typeof result.current.height).toBe('number')
expect(typeof result.current.width).toBe('number')
expect(result.current.ref).toBeInstanceOf(Function)
})

it('should match the corresponding height', () => {
Expand Down
58 changes: 29 additions & 29 deletions packages/usehooks-ts/src/useElementSize/useElementSize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,30 @@ import { useCallback, useState } from 'react'
import { useEventListener } from '../useEventListener'
import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'

interface Size<W extends number = number, T extends number = number> {
width: W
height: T
type Size = {
width: number | undefined
height: number | undefined
}

type UseElementSizeOptions<InitializeWithValue extends boolean | undefined> = {
initializeWithValue: InitializeWithValue
type UseElementSizeOptions = {
initializeWithValue?: boolean
}

const IS_SERVER = typeof window === 'undefined'
/** Supports both array and object destructing */
type UseElementSizeResult = [(node: Element | null) => void, Size] &
(Size & { ref: (node: Element | null) => void })

// SSR version of useElementSize.
export function useElementSize<T extends HTMLElement = HTMLDivElement>(
options: UseElementSizeOptions<false>,
): [(node: T | null) => void, Size<0, 0>]
// CSR version of useElementSize.
export function useElementSize<T extends HTMLElement = HTMLDivElement>(
options?: Partial<UseElementSizeOptions<true>>,
): [(node: T | null) => void, Size]
/**
* A hook for tracking the size of a DOM element.
* @template T - The type of the DOM element. Defaults to `HTMLDivElement`.
* @param {?UseElementSizeOptions} [options] - The options for customizing the behavior of the hook (optional).
* @param {?boolean} [options.initializeWithValue] - If `true` (default), the hook will initialize reading the element's size. In SSR, you should set it to `false`, returning `{ width: 0, height: 0 }` initially.
* @returns {[ (node: T | null) => void, Size ]} A tuple containing a ref-setting function and the size of the element.
* @param {?boolean} [options.initializeWithValue] - If `true` (default), the hook will initialize reading the element's size. In SSR, you should set it to `false`.
* @returns The ref-setting function and the size of the element. Either as an tuple [ref, size] or as an object { ref, width, height }.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-element-size)
* @example
* const [ref, size] = useElementSize();
* const [ref, { width = 0, height = 0 }] = useElementSize();
* // or
* const { ref, width = 0, height = 0 } = useElementSize();
*
* return (
* <div ref={ref}>
Expand All @@ -39,30 +35,27 @@ export function useElementSize<T extends HTMLElement = HTMLDivElement>(
* );
*/
export function useElementSize<T extends HTMLElement = HTMLDivElement>(
options: Partial<UseElementSizeOptions<boolean>> = {},
): [(node: T | null) => void, Size] {
let { initializeWithValue = true } = options
if (IS_SERVER) {
initializeWithValue = false
}
options: UseElementSizeOptions = {},
): UseElementSizeResult {
const { initializeWithValue = true } = options

// Mutable values like 'ref.current' aren't valid dependencies
// because mutating them doesn't re-render the component.
// Instead, we use a state as a ref to be reactive.
const [ref, setRef] = useState<T | null>(null)

const readValue = useCallback(() => {
const readValue = useCallback<() => Size>(() => {
return {
width: ref?.offsetWidth ?? 0,
height: ref?.offsetHeight ?? 0,
width: ref?.offsetWidth ?? undefined,
height: ref?.offsetHeight ?? undefined,
}
}, [ref?.offsetHeight, ref?.offsetWidth])

const [size, setSize] = useState(() => {
const [size, setSize] = useState<Size>(() => {
if (initializeWithValue) {
return readValue()
}
return { width: 0, height: 0 }
return { width: undefined, height: undefined }
})

// Prevent too many rendering using useCallback
Expand All @@ -80,5 +73,12 @@ export function useElementSize<T extends HTMLElement = HTMLDivElement>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref?.offsetHeight, ref?.offsetWidth])

return [setRef, size]
const result = [setRef, size] as UseElementSizeResult

// Support object destructuring
result.ref = result[0]
result.width = size.width
result.height = size.height

return result
}
26 changes: 18 additions & 8 deletions packages/usehooks-ts/src/useLocalStorage/useLocalStorage.demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@ import { useLocalStorage } from './useLocalStorage'

// Usage
export default function Component() {
const [isDarkTheme, setDarkTheme] = useLocalStorage('darkTheme', true)

const toggleTheme = () => {
setDarkTheme((prevValue: boolean) => !prevValue)
}
const [value, setValue] = useLocalStorage('test-key', 0)

return (
<button onClick={toggleTheme}>
{`The current theme is ${isDarkTheme ? `dark` : `light`}`}
</button>
<div>
<p>Count: {value}</p>
<button
onClick={() => {
setValue((x: number) => x + 1)
}}
>
Increment
</button>
<button
onClick={() => {
setValue((x: number) => x - 1)
}}
>
Decrement
</button>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ This hook is used in the same way as useState except that you must pass the stor

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

**Note**: If you use this hook in an SSR context, set the `initializeWithValue` option to `false`.
**Note**: If you use this hook in an SSR context, set the `initializeWithValue` option to `false`, it will initialize in SSR with the initial value.

### Related hooks

Expand Down
33 changes: 7 additions & 26 deletions packages/usehooks-ts/src/useLocalStorage/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,21 @@ declare global {
}
}

interface UseLocalStorageOptions<
T,
InitializeWithValue extends boolean | undefined,
> {
interface UseLocalStorageOptions<T> {
serializer?: (value: T) => string
deserializer?: (value: string) => T
initializeWithValue: InitializeWithValue
initializeWithValue?: boolean
}

const IS_SERVER = typeof window === 'undefined'

// SSR version of useLocalStorage.
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options: UseLocalStorageOptions<T, false>,
): [T | undefined, Dispatch<SetStateAction<T>>]

// CSR version of useLocalStorage.
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options?: Partial<UseLocalStorageOptions<T, boolean>>,
): [T, Dispatch<SetStateAction<T>>]
/**
* Custom hook for using local storage to persist state across page reloads.
* @template T - The type of the state to be stored in local storage.
* @param {string} key - The key under which the value will be stored in local storage.
* @param {T | (() => T)} initialValue - The initial value of the state or a function that returns the initial value.
* @param {UseLocalStorageOptions<T>} [options] - Options for customizing the behavior of serialization and deserialization (optional).
* @param {?boolean} [options.initializeWithValue] - If `true` (default), the hook will initialize reading the local storage. In SSR, you should set it to `false`, returning `undefined` initially.
* @param {?boolean} [options.initializeWithValue] - If `true` (default), the hook will initialize reading the local storage. In SSR, you should set it to `false`, returning the initial value initially.
* @param {?((value: T) => string)} [options.serializer] - A function to serialize the value before storing it.
* @param {?((value: string) => T)} [options.deserializer] - A function to deserialize the stored value.
* @returns {[T, Dispatch<SetStateAction<T>>]} A tuple containing the stored value and a function to set the value.
Expand All @@ -54,12 +38,9 @@ export function useLocalStorage<T>(
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options: Partial<UseLocalStorageOptions<T, boolean>> = {},
): [T | undefined, Dispatch<SetStateAction<T>>] {
let { initializeWithValue = true } = options
if (IS_SERVER) {
initializeWithValue = false
}
options: UseLocalStorageOptions<T> = {},
): [T, Dispatch<SetStateAction<T>>] {
const { initializeWithValue = true } = options

const serializer = useCallback<(value: T) => string>(
value => {
Expand Down Expand Up @@ -122,7 +103,7 @@ export function useLocalStorage<T>(
if (initializeWithValue) {
return readValue()
}
return undefined
return initialValue instanceof Function ? initialValue() : initialValue
})

// Return a wrapped version of useState's setter function that ...
Expand Down
Loading

0 comments on commit 87ba579

Please sign in to comment.