Skip to content

Instantly share code, notes, and snippets.

@ypresto
Last active August 2, 2023 13:35
Show Gist options
  • Save ypresto/4d78f7d9d30a46c2d44937a79ee84cef to your computer and use it in GitHub Desktop.
Save ypresto/4d78f7d9d30a46c2d44937a79ee84cef to your computer and use it in GitHub Desktop.
Hooks to mitigate calling setState from useEffect/useLayoutEffect. Zero-rerender on hook value change. See useOverrideValue.ts first. https://zenn.dev/ypresto/articles/02f7adcb7c57b4
import { useRef } from 'react'
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
/**
* Hook that its return value increments when the flag is changed to true.
* You can use this with useOverrideValue() / useEffect() to dispatch only when specific condition is met,
* each time or at first (=== 1).
*/
export function useFlipCount(flag: boolean) {
const ref = useRef({ flag, count: 0 })
useIsomorphicLayoutEffect(() => {
ref.current.flag = flag
if (flag) {
ref.current.count++
}
}, [flag])
return flag && !ref.current.flag ? ref.current.count + 1 : ref.current.count
}
import { useEffect, useLayoutEffect } from 'react'
// Helper for next.js
// https://github.com/mui-org/material-ui/issues/15798#issuecomment-495078241
const canUseDOM =
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
export const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect
import { renderHook, act } from '@testing-library/react-hooks'
import { useOverrideValue } from './useOverrideValue'
describe('useOverrideValue', () => {
test('returns default value when setValue() is not called', () => {
let defaultValue = 0
const { result, rerender } = renderHook(() => useOverrideValue(defaultValue, [0]))
expect(result.current[0]).toBe(0)
defaultValue = 1
rerender()
expect(result.current[0]).toBe(1)
})
test('returns overridden value until deps is changed', () => {
let defaultValue = 0
let dep = false
const { result, rerender } = renderHook(() => useOverrideValue(defaultValue, [dep]))
act(() => {
const [, setState] = result.current
setState(1)
})
expect(result.current[0]).toBe(1)
defaultValue = 2
rerender()
expect(result.current[0]).toBe(1)
dep = true
rerender()
expect(result.current[0]).toBe(2)
})
test('supports multiple deps', () => {
let dep1 = false
let dep2 = ''
const { result, rerender } = renderHook(() => useOverrideValue(0, [dep1, dep2]))
act(() => {
const [, setState] = result.current
setState(1)
})
dep1 = true
rerender()
expect(result.current[0]).toBe(0)
act(() => {
const [, setState] = result.current
setState(1)
})
dep2 = 'foo'
rerender()
expect(result.current[0]).toBe(0)
})
test('restoring deps to previous value does not return overridden value', () => {
let dep = false
const { result, rerender } = renderHook(() => useOverrideValue(0, [dep]))
act(() => {
const [, setState] = result.current
setState(1)
})
dep = true
rerender()
expect(result.current[0]).toBe(0)
dep = false
rerender()
expect(result.current[0]).toBe(0)
})
test('setValue with callback receives current value', () => {
let defaultValue = 0
let dep = false
const { result, rerender } = renderHook(() => useOverrideValue(defaultValue, [dep]))
defaultValue = 1
rerender()
act(() => {
const [, setState] = result.current
setState(value => {
expect(value).toBe(1)
return 2
})
})
act(() => {
const [, setState] = result.current
setState(value => {
expect(value).toBe(2)
return 3
})
// next render uses new default value
defaultValue = 4
dep = true
})
act(() => {
const [, setState] = result.current
setState(value => {
expect(value).toBe(4)
return 0
})
})
})
test('ref of setValue does not change', () => {
let defaultValue = 0
let dep = false
const { result, rerender } = renderHook(() => useOverrideValue(defaultValue, [dep]))
const [, setValue] = result.current
defaultValue = 1
rerender()
expect(result.current[1]).toBe(setValue)
dep = true
rerender()
expect(result.current[1]).toBe(setValue)
act(() => {
const [, setState] = result.current
setState(2)
})
expect(result.current[1]).toBe(setValue)
})
})
import { useReducer, useRef } from 'react'
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
/**
* useState() which resets to defaultValue when one of deps is changed.
*/
export function useOverrideValue<T>(defaultValue: T, deps: unknown[] = [defaultValue]) {
type UpdateFunc = (prevState: T) => T
const [, forceUpdate] = useReducer(x => x + 1, 0)
const stateRef = useRef<{ defaultValue: T; deps: unknown[]; override?: { value: T; snapshot: unknown[] } }>({
defaultValue,
deps,
})
if (stateRef.current.override && deps.length !== stateRef.current.override.snapshot.length) {
throw new Error('useOverrideValue: deps array length should not be changed.')
}
const setValue = useRef((value: T | UpdateFunc) => {
const state = stateRef.current
const currentValue = state.override?.snapshot.every((v, i) => state.deps[i] === v)
? state.override.value
: state.defaultValue
const newValue = typeof value === 'function' ? (value as UpdateFunc)(currentValue) : value
stateRef.current.override = { value: newValue, snapshot: stateRef.current.deps }
if (newValue !== currentValue) {
forceUpdate()
}
}).current
useIsomorphicLayoutEffect(() => {
// These can be used by setOverride() call, but below code must be called earlier than useLayoutEffect() in children.
stateRef.current.override = undefined
stateRef.current.deps = deps
}, deps)
useIsomorphicLayoutEffect(() => {
stateRef.current.defaultValue = defaultValue
}, [defaultValue])
const value = stateRef.current.override?.snapshot.every((v, i) => deps[i] === v)
? stateRef.current.override.value
: defaultValue
return [value, setValue] as const
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment