Skip to content

Commit

Permalink
✨ Feature: declarative SSR (#451)
Browse files Browse the repository at this point in the history
* ✨ Make hooks SSR-safe and type-safe (see comment)

Pass an optional `{ initializeWithValue?: boolean }` object to make them SSR-safe
- useElementSize
- useMediaQuery
- useScreen
- useSessionStorage
- useLocalStorage
- useWindowSize
- useReadLocalStorage
- useDarkMode
- useTernaryDarkMode

* 🔖 Add changeset

* 📝 Update the documentation
  • Loading branch information
juliencrn committed Jan 31, 2024
1 parent 5c210c1 commit 4a9fc88
Show file tree
Hide file tree
Showing 22 changed files with 352 additions and 136 deletions.
5 changes: 5 additions & 0 deletions .changeset/angry-otters-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'usehooks-ts': minor
---

Pass an optional `{ initializeWithValue?: boolean }` object to make some hooks SSR-safe (useLocalStorage, useReadLocalStorage, useSessionStorage, useDarkMode, useTernaryDarkMode, useMediaQuery, useScreen, useWindowSize, useElementSize)
5 changes: 5 additions & 0 deletions .changeset/gorgeous-donuts-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"usehooks-ts": minor
---

Drop Set & Map support in use\*Storage hooks (it was a done before in deserializer, but forgotten in serializer)
2 changes: 2 additions & 0 deletions packages/usehooks-ts/src/useDarkMode/useDarkMode.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ This React Hook offers you an interface to set, enable, disable, toggle and read
The returned value (`isDarkMode`) is a boolean to let you be able to use with your logic.

It uses internally [`useLocalStorage()`](/react-hook/use-local-storage) to persist the value and listens the OS color scheme preferences.

**Note**: If you use this hook in an SSR context, set the `initializeWithValue` option to `false`.
46 changes: 25 additions & 21 deletions packages/usehooks-ts/src/useDarkMode/useDarkMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { useUpdateEffect } from '../useUpdateEffect'
const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'
const LOCAL_STORAGE_KEY = 'usehooks-ts-dark-mode'

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

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

/**
* Custom hook that returns the current state of the dark mode.
* @param {?DarkModeOptions} [options] - Options for the hook.
* @param {?string} [options.localStorageKey] - The key for storing dark mode preference in local storage (default is `'usehooks-ts-ternary-dark-mode'`).
* @param {?boolean} [options.defaultValue] - Default value if there's nothing set in local storage (default is `false`).
* @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: false,
* localStorageKey: 'my-key',
* });
*/
export function useDarkMode(options?: DarkModeOptions): DarkModeOutput
// 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>

/**
* 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 | ?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 {?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 | DarkModeOptions,
options?: boolean | Partial<DarkModeOptions<boolean>>,
localStorageKeyProps: string = LOCAL_STORAGE_KEY,
): DarkModeOutput {
): DarkModeOutput<boolean | undefined> {
// TODO: Refactor this code after the deprecated signature has been removed.
const defaultValue =
typeof options === 'boolean' ? options : options?.defaultValue ?? false
const localStorageKey =
typeof options === 'boolean'
? localStorageKeyProps ?? LOCAL_STORAGE_KEY
: options?.localStorageKey ?? LOCAL_STORAGE_KEY
const initializeWithValue =
typeof options === 'boolean'
? undefined
: options?.initializeWithValue ?? undefined

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

// Update darkMode if os prefers changes
Expand Down
2 changes: 2 additions & 0 deletions packages/usehooks-ts/src/useElementSize/useElementSize.md
Original file line number Diff line number Diff line change
@@ -1,2 +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`.
56 changes: 42 additions & 14 deletions packages/usehooks-ts/src/useElementSize/useElementSize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,30 @@ import { useCallback, useState } from 'react'
import { useEventListener } from '../useEventListener'
import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'

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

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

const IS_SERVER = typeof window === 'undefined'

// 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.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-element-size)
* @example
Expand All @@ -22,29 +38,41 @@ interface Size {
* </div>
* );
*/
export function useElementSize<T extends HTMLElement = HTMLDivElement>(): [
(node: T | null) => void,
Size,
] {
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
}

// 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 [size, setSize] = useState<Size>({
width: 0,
height: 0,

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

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

// Prevent too many rendering using useCallback
const handleSize = useCallback(() => {
setSize({
width: ref?.offsetWidth ?? 0,
height: ref?.offsetHeight ?? 0,
})
setSize(readValue())

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref?.offsetHeight, ref?.offsetWidth])

// TODO: Prefer incoming useResizeObserver hook
useEventListener('resize', handleSize)

useIsomorphicLayoutEffect(() => {
Expand Down
14 changes: 6 additions & 8 deletions packages/usehooks-ts/src/useLocalStorage/useLocalStorage.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
Persist the state with local storage so that it remains after a page refresh. This can be useful for a dark theme.
Persist the state with [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) 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.

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

**Side notes:**
**Note**: If you use this hook in an SSR context, set the `initializeWithValue` option to `false`.

- 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

Related hooks:

- [`useSessionStorage()`](/react-hook/use-session-storage)
- [`useDarkMode()`](/react-hook/use-dark-mode): Helps create a dark theme switch, built on top of `useLocalStorage()`.
- [`useReadLocalStorage()`](/react-hook/use-read-local-storage): Read values from local storage.
- [`useSessionStorage()`](/react-hook/use-session-storage): Its implementation is almost the same of `useLocalStorage()`, but on [session storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) instead.
60 changes: 36 additions & 24 deletions packages/usehooks-ts/src/useLocalStorage/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,39 @@ declare global {
}
}

/**
* Represents the options for customizing the behavior of serialization and deserialization.
* @template T - The type of the state to be stored in local storage.
* @interface Options
* @property {(value: T) => string} [serializer] - A function to serialize the value before storing it.
* @property {(value: string) => T} [deserializer] - A function to deserialize the stored value.
*/
interface Options<T> {
interface UseLocalStorageOptions<
T,
InitializeWithValue extends boolean | undefined,
> {
serializer?: (value: T) => string
deserializer?: (value: string) => T
initializeWithValue: InitializeWithValue
}

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

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 {Options<T>} [options] - Options for customizing the behavior of serialization and deserialization (optional).
* @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 {?((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.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-local-storage)
* @see [MDN Local Storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)
Expand All @@ -47,25 +58,19 @@ const IS_SERVER = typeof window === 'undefined'
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options: Options<T> = {},
): [T, SetValue<T>] {
// Pass initial value to support hydration server-client
const [storedValue, setStoredValue] = useState<T>(initialValue)
options: Partial<UseLocalStorageOptions<T, boolean>> = {},
): [T | undefined, Dispatch<SetStateAction<T>>] {
let { initializeWithValue = true } = options
if (IS_SERVER) {
initializeWithValue = false
}

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],
Expand Down Expand Up @@ -117,9 +122,16 @@ export function useLocalStorage<T>(
}
}, [initialValue, key, deserializer])

const [storedValue, setStoredValue] = useState(() => {
if (initializeWithValue) {
return readValue()
}
return undefined
})

// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = useEventCallback(value => {
const setValue: Dispatch<SetStateAction<T>> = useEventCallback(value => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
Expand Down
3 changes: 2 additions & 1 deletion packages/usehooks-ts/src/useMediaQuery/useMediaQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ Easily retrieve media dimensions with this Hook React which also works onResize.

**Note:**

Before Safari 14, `MediaQueryList` is based on `EventTarget` and only supports `addListener`/`removeListener` for media queries. If you don't support these versions you may remove these checks. Read more about this on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/addListener).
- If you use this hook in an SSR context, set the `initializeWithValue` option to `false`.
- Before Safari 14, `MediaQueryList` is based on `EventTarget` and only supports `addListener`/`removeListener` for media queries. If you don't support these versions you may remove these checks. Read more about this on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/addListener).
56 changes: 54 additions & 2 deletions packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@ import { useState } from 'react'

import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'

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

const IS_SERVER = typeof window === 'undefined'

/**
* Custom hook for tracking the state of a media query.
* @deprecated - this useMediaQuery's signature is deprecated, it now accepts an query parameter and an options object.
* @param {string} query - The media query to track.
* @param {?boolean} [defaultValue] - The default value to return if the hook is being run on the server (default is `false`).
* @returns {boolean} The current state of the media query (true if the query matches, false otherwise).
Expand All @@ -13,8 +21,52 @@ import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'
* const isSmallScreen = useMediaQuery('(max-width: 600px)');
* // Use `isSmallScreen` to conditionally apply styles or logic based on the screen size.
*/
export function useMediaQuery(query: string, defaultValue = false): boolean {
const [matches, setMatches] = useState<boolean>(defaultValue)
export function useMediaQuery(query: string, defaultValue: boolean): boolean // defaultValue should be false by default
// SSR version of useMediaQuery.
export function useMediaQuery(
query: string,
options: UseMediaQueryOptions<false>,
): boolean | undefined
// CSR version of useMediaQuery.
export function useMediaQuery(
query: string,
options?: Partial<UseMediaQueryOptions<true>>,
): boolean
/**
* Custom hook for tracking the state of a media query.
* @param {string} query - The media query to track.
* @param {boolean | ?UseMediaQueryOptions} [options] - The default value to return if the hook is being run on the server (default is `false`).
* @param {?boolean} [options.defaultValue] - The default value to return if the hook is being run on the server (default is `false`).
* @param {?boolean} [options.initializeWithValue] - If `true` (default), the hook will initialize reading the media query. In SSR, you should set it to `false`, returning `undefined` or `options.defaultValue` initially.
* @returns {boolean | undefined} The current state of the media query (true if the query matches, false otherwise).
* @see [Documentation](https://usehooks-ts.com/react-hook/use-media-query)
* @see [MDN Match Media](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia)
* @example
* const isSmallScreen = useMediaQuery('(max-width: 600px)');
* // Use `isSmallScreen` to conditionally apply styles or logic based on the screen size.
*/
export function useMediaQuery(
query: string,
options?: boolean | Partial<UseMediaQueryOptions<boolean>>,
): boolean | undefined {
// TODO: Refactor this code after the deprecated signature has been removed.
const defaultValue =
typeof options === 'boolean' ? options : options?.defaultValue ?? false
let initializeWithValue =
typeof options === 'boolean'
? undefined
: options?.initializeWithValue ?? undefined

if (IS_SERVER) {
initializeWithValue = false
}

const [matches, setMatches] = useState<boolean>(() => {
if (initializeWithValue) {
return getMatches(query)
}
return defaultValue
})

const getMatches = (query: string): boolean => {
if (typeof window !== 'undefined') {
Expand Down
Loading

0 comments on commit 4a9fc88

Please sign in to comment.