|
| 1 | +import { useEffect, useRef } from 'react'; |
| 2 | + |
| 3 | +import { useSafeState } from './useSafeState'; |
| 4 | + |
| 5 | +type UseSpinDelayOptions = { |
| 6 | + /** |
| 7 | + * The amount of time (in ms) to wait before reflecting `value === true`. If `value` flips back |
| 8 | + * to `false` before this elapses, the flag is never set to `true` (the spinner is skipped). |
| 9 | + * |
| 10 | + * @default 0 |
| 11 | + */ |
| 12 | + delay?: number; |
| 13 | + /** |
| 14 | + * Once the flag becomes `true`, it stays `true` for at least this long (in ms) even if the |
| 15 | + * underlying `value` returns to `false` earlier. Prevents the spinner from flickering on fast |
| 16 | + * operations. |
| 17 | + * |
| 18 | + * @default 425 |
| 19 | + */ |
| 20 | + minDuration?: number; |
| 21 | +}; |
| 22 | + |
| 23 | +const DEFAULT_DELAY = 0; |
| 24 | +// 425ms is the default used by the dashboard (`apps/dashboard/app/hooks/use-pending-hooks.ts`), |
| 25 | +// chosen by trial-and-error as the threshold above which a spinner feels intentional rather |
| 26 | +// than a flicker. |
| 27 | +const DEFAULT_MIN_DURATION = 425; |
| 28 | + |
| 29 | +/** |
| 30 | + * Smooths a transient boolean flag (typically a loading/fetching state) so the consumer never |
| 31 | + * shows a spinner that flickers on and off within a single frame. |
| 32 | + * |
| 33 | + * @example |
| 34 | + * const isFetching = useSomeQuery(); |
| 35 | + * const showSpinner = useSpinDelay(isFetching, { delay: 0, minDuration: 425 }); |
| 36 | + * return showSpinner ? <Spinner /> : <Icon />; |
| 37 | + */ |
| 38 | +export function useSpinDelay( |
| 39 | + value: boolean, |
| 40 | + { delay = DEFAULT_DELAY, minDuration = DEFAULT_MIN_DURATION }: UseSpinDelayOptions = {}, |
| 41 | +): boolean { |
| 42 | + const [displayed, setDisplayed] = useSafeState(false); |
| 43 | + const shownAtRef = useRef<number | null>(null); |
| 44 | + |
| 45 | + useEffect(() => { |
| 46 | + if (value && !displayed) { |
| 47 | + const timeout = setTimeout(() => { |
| 48 | + shownAtRef.current = Date.now(); |
| 49 | + setDisplayed(true); |
| 50 | + }, delay); |
| 51 | + return () => clearTimeout(timeout); |
| 52 | + } |
| 53 | + |
| 54 | + if (!value && displayed) { |
| 55 | + const elapsed = shownAtRef.current != null ? Date.now() - shownAtRef.current : minDuration; |
| 56 | + const remaining = Math.max(0, minDuration - elapsed); |
| 57 | + const timeout = setTimeout(() => { |
| 58 | + shownAtRef.current = null; |
| 59 | + setDisplayed(false); |
| 60 | + }, remaining); |
| 61 | + return () => clearTimeout(timeout); |
| 62 | + } |
| 63 | + }, [value, displayed, delay, minDuration, setDisplayed]); |
| 64 | + |
| 65 | + return displayed; |
| 66 | +} |
0 commit comments