Skip to content

Commit

Permalink
✨ handle SSR hydration mismatches
Browse files Browse the repository at this point in the history
  • Loading branch information
astoilkov committed Jan 7, 2022
1 parent 98e09fa commit 2bcbc52
Show file tree
Hide file tree
Showing 7 changed files with 107 additions and 6 deletions.
9 changes: 8 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import useLocalStorageState from './src/useLocalStorageState'
import useSsrLocalStorageState from './src/useSsrLocalStorageState'
import createLocalStorageStateHook from './src/createLocalStorageStateHook'
import createSsrLocalStorageStateHook from './src/createSsrLocalStorageStateHook'

export default useLocalStorageState
export { useLocalStorageState, createLocalStorageStateHook }
export {
useLocalStorageState,
useSsrLocalStorageState,
createLocalStorageStateHook,
createSsrLocalStorageStateHook,
}
8 changes: 8 additions & 0 deletions src/createSsrLocalStorageStateHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import withSsr from './withSsr'
import createLocalStorageStateHook from './createLocalStorageStateHook'

export default function createSsrLocalStorageStateHook(
...args: Parameters<typeof createLocalStorageStateHook>
): ReturnType<typeof createLocalStorageStateHook> {
return withSsr(createLocalStorageStateHook(...args))
}
4 changes: 4 additions & 0 deletions src/unwrapValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default function unwrapValue<T>(value: T | (() => T)): T {
const isCallable = (value: unknown): value is () => T => typeof value === 'function'
return isCallable(value) ? value() : value
}
9 changes: 5 additions & 4 deletions src/useLocalStorageStateBase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import storage from './storage'
import { useEffect, useMemo, useRef, useState } from 'react'
import unwrapValue from './unwrapValue'

export type UpdateState<T> = (newValue: T | ((value: T) => T)) => void
export type SetStateParameter<T> = T | undefined | ((value: T | undefined) => T | undefined)
Expand All @@ -19,14 +20,14 @@ export default function useLocalStorageStateBase<T = undefined>(
key: string,
defaultValue?: T | (() => T),
): [T | undefined, UpdateState<T | undefined>, LocalStorageProperties] {
const unwrappedDefaultValue = useMemo(() => {
const isCallable = (value: unknown): value is () => T => typeof value === 'function'
return isCallable(defaultValue) ? defaultValue() : defaultValue
const unwrappedDefaultValue = useMemo(
() => unwrapValue(defaultValue),

// disabling "exhaustive-deps" because we don't want to change the default state when the
// `defaultValue` is changed
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key])
[key],
)
const defaultState = useMemo(() => {
return {
value: storage.get(key, unwrappedDefaultValue),
Expand Down
4 changes: 4 additions & 0 deletions src/useSsrLocalStorageState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import withSsr from './withSsr'
import useLocalStorageState from './useLocalStorageState'

export default withSsr(useLocalStorageState)
37 changes: 37 additions & 0 deletions src/withSsr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import unwrapValue from './unwrapValue'
import useLocalStorageState from './useLocalStorageState'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'

export default function withSsr<T extends typeof useLocalStorageState>(useLocalStorageHook: T): T {
const useLocalStorageStateWithSsr = (...args: Parameters<T>): ReturnType<T> => {
const defaultValue = args[1]
const isFirstRender = useRef(true)
const [_, forceUpdate] = useState(false)
const isServer = typeof window === 'undefined'
const useIsomorphicEffect = isServer ? useEffect : useLayoutEffect

// asdf
// eslint-disable-next-line prefer-spread
const localStorageState = useLocalStorageHook.apply(undefined, args)

useIsomorphicEffect(() => {
if (unwrapValue(defaultValue) !== localStorageState[0]) {
forceUpdate(true)
}

isFirstRender.current = false
}, [])

if (isFirstRender.current) {
return [
unwrapValue(defaultValue),
localStorageState[1],
localStorageState[2],
] as ReturnType<T>
}

return localStorageState as ReturnType<T>
}

return useLocalStorageStateWithSsr as unknown as T
}
42 changes: 41 additions & 1 deletion test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { createElement, useMemo } from 'react'
import { renderToString } from 'react-dom/server'
import { renderHook, act } from '@testing-library/react-hooks'
import { useLocalStorageState, createLocalStorageStateHook } from '.'
import {
useLocalStorageState,
createLocalStorageStateHook,
useSsrLocalStorageState,
createSsrLocalStorageStateHook
} from '.'

beforeEach(() => {
localStorage.clear()
Expand Down Expand Up @@ -496,3 +501,38 @@ describe('createLocalStorageStateHook()', () => {
expect(localStorage.getItem('todos')).toEqual(null)
})
})

describe('useSsrLocalStorageState()', () => {
it('basic setup with default value', () => {
const { result } = renderHook(() => useSsrLocalStorageState('todos', [1, 2, 3]))

const [todos] = result.current
expect(todos).toEqual([1, 2, 3])
})

it('turns server rendering when `window` object is `undefined`', () => {
const windowSpy = jest.spyOn(global, 'window' as any, 'get')
windowSpy.mockImplementation(() => {
return undefined
})

function Component() {
const [todos] = useSsrLocalStorageState('todos', [1, 2, 3])
expect(todos).toEqual([1, 2, 3])
return null
}
renderToString(createElement(Component))

windowSpy.mockRestore()
})
})

describe('createSsrLocalStorageStateHook()', () => {
it('basic setup with default value', () => {
const useTodos = createSsrLocalStorageStateHook('todos', [1, 2, 3])
const { result } = renderHook(() => useTodos())

const [todos] = result.current
expect(todos).toEqual([1, 2, 3])
})
})

0 comments on commit 2bcbc52

Please sign in to comment.