From a1b62eb88e570ca47b6cac6119ff80d469eacdc4 Mon Sep 17 00:00:00 2001 From: Leroy Korterink Date: Fri, 3 May 2024 16:17:21 +0200 Subject: [PATCH] Make hooks client-side rendering and server-side rendering compatible --- src/hooks/useContentRect/useContentRect.ts | 5 ++- .../useContentRectState.ts | 9 ++++- .../useMediaDuration/useMediaDuration.ts | 4 +- src/hooks/useMediaQuery/useMediaQuery.mdx | 37 ++++++++++++++++--- .../useMediaQuery/useMediaQuery.stories.tsx | 23 +++++++++--- src/hooks/useMediaQuery/useMediaQuery.ts | 10 +++-- 6 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/hooks/useContentRect/useContentRect.ts b/src/hooks/useContentRect/useContentRect.ts index 9ca1d17..20f1f68 100644 --- a/src/hooks/useContentRect/useContentRect.ts +++ b/src/hooks/useContentRect/useContentRect.ts @@ -9,8 +9,11 @@ import { useResizeObserver } from '../useResizeObserver/useResizeObserver.js'; */ export function useContentRect( target: Unreffable, + serverSideRendering = false, ): RefObject { - const contentRectRef = useRef(null); + const contentRectRef = useRef( + serverSideRendering ? null : unref(target)?.getBoundingClientRect() ?? null, + ); const onResize = useCallback((entries): void => { contentRectRef.current = entries.at(0)?.contentRect ?? null; diff --git a/src/hooks/useContentRectState/useContentRectState.ts b/src/hooks/useContentRectState/useContentRectState.ts index f663073..a6363e2 100644 --- a/src/hooks/useContentRectState/useContentRectState.ts +++ b/src/hooks/useContentRectState/useContentRectState.ts @@ -7,8 +7,13 @@ import { useResizeObserver } from '../useResizeObserver/useResizeObserver.js'; * A hook that returns the content rectangle of the target element. * The content rectangle is updated whenever the target element is resized. */ -export function useContentRectState(target: Unreffable): DOMRectReadOnly | null { - const [contentRect, setContentRect] = useState(null); +export function useContentRectState( + target: Unreffable, + serverSideRendering = false, +): DOMRectReadOnly | null { + const [contentRect, setContentRect] = useState( + serverSideRendering ? null : unref(target)?.getBoundingClientRect() ?? null, + ); const rafRef = useRef(0); const onResize = useCallback((entries) => { diff --git a/src/hooks/useMediaDuration/useMediaDuration.ts b/src/hooks/useMediaDuration/useMediaDuration.ts index 07c5b6a..a30c807 100644 --- a/src/hooks/useMediaDuration/useMediaDuration.ts +++ b/src/hooks/useMediaDuration/useMediaDuration.ts @@ -6,7 +6,9 @@ import { type MutableRefObject, useCallback, useEffect, useState } from 'react'; export function useMediaDuration( mediaElementRef: MutableRefObject, ): number { - const [mediaDuration, setMediaDuration] = useState(Number.NaN); + const [mediaDuration, setMediaDuration] = useState( + mediaElementRef.current?.duration ?? Number.NaN, + ); const updateDuration = useCallback(() => { setMediaDuration(mediaElementRef.current?.duration ?? Number.NaN); diff --git a/src/hooks/useMediaQuery/useMediaQuery.mdx b/src/hooks/useMediaQuery/useMediaQuery.mdx index ae4c965..a883053 100644 --- a/src/hooks/useMediaQuery/useMediaQuery.mdx +++ b/src/hooks/useMediaQuery/useMediaQuery.mdx @@ -12,9 +12,22 @@ Hook that returns true when the media query matches. function useMediaQuery(mediaQueryOrVariableName: string, defaultValue = false): boolean; ``` -## Usage +## Using a media query string -Define the media query in your CSS variables. +Use the hook in your component using a media query string. + +```tsx +function DemoComponent() { + const isMinWidth480px = useMediaQuery('(min-width: 480px)'); + + return
{isMinWidth480px ? 'large' : 'small'}
; +} +``` + +## Using a CSS variable + +To use the hook in your component using the CSS variable, define the media query in your CSS +variables. ```css :root { @@ -22,13 +35,25 @@ Define the media query in your CSS variables. } ``` -Use the hook in your component using the CSS variable or a custom media query. +Use the media query variable as the first argument. + +```tsx +function DemoComponent() { + const isMinWidth480px = useMediaQuery('--min-width-480'); + + return
{isMinWidth480px ? 'large' : 'small'}
; +} +``` + +## Server-side rendering + +The hook returns the default value when the `matchMedia` API is not available (for example in +server-side rendering). Set a default value if you get hydration mismatch errors. ```tsx function DemoComponent() { - const isMinWidth480pxUsingVar = useMediaQuery('--min-width-480'); - const isMinWidth480pxUsingQuery = useMediaQuery('(min-width: 480px)'); + const isMinWidth480px = useMediaQuery('(min-width: 480px)', true); - return
{isMinWidth480pxUsingVar && isMinWidth480pxUsingQuery ? 'large' : 'small'}
; + return
{isMinWidth480px ? 'large' : 'small'}
; } ``` diff --git a/src/hooks/useMediaQuery/useMediaQuery.stories.tsx b/src/hooks/useMediaQuery/useMediaQuery.stories.tsx index 422b6b2..623e1fc 100644 --- a/src/hooks/useMediaQuery/useMediaQuery.stories.tsx +++ b/src/hooks/useMediaQuery/useMediaQuery.stories.tsx @@ -19,8 +19,9 @@ const css = ` } `; -function DemoComponent(): ReactElement { - const isMinWidth420px = useMediaQuery('--min-width-420'); +type DemoComponentProps = { defaultValue?: boolean }; +function DemoComponent({ defaultValue }: DemoComponentProps): ReactElement { + const value = useMediaQuery('--min-width-420', defaultValue); return ( <> @@ -31,8 +32,18 @@ function DemoComponent(): ReactElement {

Resize the viewport to see the useMediaQuery hook in action.

+ {`:root { + --min-width-420: (min-width: 420px); +}\n\n`} +
- {isMinWidth420px ? 'Viewport is wider than 420px' : 'Viewport is narrower than 420px'} + Default value: {String(defaultValue)} +
+ Matches: {String(value)}
); @@ -58,7 +69,7 @@ export const Mobile: Story = { }, async play({ canvasElement }) { const canvas = within(canvasElement); - canvas.getByText('Viewport is narrower than 420px'); + expect(canvas.getByTestId('value').textContent).toBe('false'); }, }; @@ -83,7 +94,7 @@ export const Desktop: Story = { async play({ canvasElement }) { const canvas = within(canvasElement); - // Using findByText because initial state is different to support SSR - expect(await canvas.findByText('Viewport is wider than 420px')).toBeVisible(); + // Using getByText because initial state is different to support SSR + expect(canvas.getByTestId('value').textContent).toBe('true'); }, }; diff --git a/src/hooks/useMediaQuery/useMediaQuery.ts b/src/hooks/useMediaQuery/useMediaQuery.ts index 4bbb7fe..82faed4 100644 --- a/src/hooks/useMediaQuery/useMediaQuery.ts +++ b/src/hooks/useMediaQuery/useMediaQuery.ts @@ -49,16 +49,18 @@ export function getMediaQueryList( * Hook that returns a boolean indicating whether the media query matches. * * @param mediaQueryOrVariableName - The name of the CSS variable that describes the media query. - * @param defaultValue - The default value to return if the matchMedia API is not available. + * @param defaultValue - The default value to return if the matchMedia API is not available (set a value to make this hook work in SSR mode). */ export function useMediaQuery( mediaQueryOrVariableName: MediaQueryValues, - defaultValue = false, -): boolean { + defaultValue?: boolean, +): boolean | undefined { const [mediaQueryList, setMediaQueryList] = useState(() => getMediaQueryList(mediaQueryOrVariableName), ); - const [matches, setMatches] = useState(defaultValue); + const [matches, setMatches] = useState( + defaultValue === undefined ? mediaQueryList?.matches : defaultValue, + ); useEffect(() => { const newMediaQueryList = getMediaQueryList(mediaQueryOrVariableName);