useDelayedLoading
A hook that defers showing a loading indicator and guarantees a minimum display time, so spinners never flash
Source Code
import { useEffect, useRef, useState } from 'react';
interface UseDelayedLoadingOptions {
delay?: number;
minDuration?: number;
}
export const useDelayedLoading = (
isLoading: boolean,
{ delay = 300, minDuration = 1000 }: UseDelayedLoadingOptions = {}
): boolean => {
const [showLoading, setShowLoading] = useState(false);
const shownAt = useRef<number | null>(null);
useEffect(() => {
if (isLoading) {
const delayId = window.setTimeout(() => {
shownAt.current = performance.now();
setShowLoading(true);
}, delay);
return () => window.clearTimeout(delayId);
}
if (shownAt.current === null) return;
const elapsed = performance.now() - shownAt.current;
const remaining = minDuration - elapsed;
const hide = () => {
shownAt.current = null;
setShowLoading(false);
};
if (remaining <= 0) {
hide();
return;
}
const minId = window.setTimeout(hide, remaining);
return () => window.clearTimeout(minId);
}, [isLoading, delay, minDuration]);
return showLoading;
}; Features
- Deferred display: the loader only appears after a short delay, so fast work never triggers a spinner
- Minimum duration: once shown, the loader stays visible long enough that it can’t flash on and off
- Zero dependencies: React only — drop it in next to any spinner, skeleton, or overlay
- Tunable: both thresholds are options with sensible defaults
When to use it
A loading indicator that appears and disappears within a frame or two reads as a glitch and, counter-intuitively, makes a product feel slower. useDelayedLoading prevents that: it withholds the loader until the wait is long enough to deserve one (the delay), and once a loader does appear it pins it in place for a floor (minDuration) so a request that resolves a moment later can’t cause a flicker.
It decides when a loader shows, not which one — pair it with a Spinner, Progress, Skeleton, or overlay. For choosing the right indicator for a given wait, see the Loading States guide.
API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
isLoading * | - | boolean | The raw loading state of your async work. |
options.delay | 300 | number | Milliseconds to wait before showing the loader. If loading finishes first, the loader never appears. |
options.minDuration | 1000 | number | Once shown, the minimum milliseconds the loader stays visible — even if loading finishes immediately after. |
The hook returns a single boolean — showLoading — which is true only while the loader should be on screen.
Examples
Naive vs. delayed
Trigger work of different lengths and compare a spinner bound directly to isLoading against one driven by useDelayedLoading. A quick request flashes the naive spinner but never shows the delayed one; a moderate request makes the delayed spinner appear late and linger past its minimum; a slow request shows both.
- Load quick (150ms): Naive flashes a spinner. Delayed shows nothing.
- Load moderate (500ms): Naive shows for the whole wait. Delayed appears later and holds.
- Load slow (2500ms): Both show a spinner; the delayed one just starts later.
import { useRef, useState } from 'react';
import { useDelayedLoading } from '@/foundations/hooks/use-delayed-loading';
import { Button } from '@/components/button';
import { Spinner } from '@/components/spinner';
import type { PreviewMeta } from '@/lib/preview';
const cases = [
{
label: 'Load quick',
duration: 150,
caption: 'Naive flashes a spinner. Delayed shows nothing.',
},
{
label: 'Load moderate',
duration: 500,
caption: 'Naive shows for the whole wait. Delayed appears later and holds.',
},
{
label: 'Load slow',
duration: 2500,
caption: 'Both show a spinner; the delayed one just starts later.',
},
];
function Indicator({ label, active }: { label: string; active: boolean }) {
return (
<div className="flex w-44 flex-col items-center gap-2 rounded-lg border border-border p-4">
<span className="text-foreground-secondary text-xs">{label}</span>
<div className="flex h-6 items-center">
{active ? (
<Spinner />
) : (
<span className="text-foreground-secondary text-xs opacity-60">
idle
</span>
)}
</div>
</div>
);
}
function UseDelayedLoadingPreview() {
const [isLoading, setIsLoading] = useState(false);
const timeout = useRef<number | null>(null);
const showLoading = useDelayedLoading(isLoading);
const load = (duration: number) => {
if (timeout.current) window.clearTimeout(timeout.current);
setIsLoading(true);
timeout.current = window.setTimeout(() => setIsLoading(false), duration);
};
return (
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap justify-center gap-2">
{cases.map((c) => (
<Button
key={c.label}
variant="outline"
disabled={isLoading}
onClick={() => load(c.duration)}
>
{c.label}
</Button>
))}
</div>
<div className="flex gap-3">
<Indicator label="Naive (raw isLoading)" active={isLoading} />
<Indicator label="useDelayedLoading" active={showLoading} />
</div>
<ul className="max-w-sm space-y-1 text-center text-foreground-secondary text-xs">
{cases.map((c) => (
<li key={c.label}>
<strong className="text-foreground">{c.label}</strong> ({c.duration}
ms): {c.caption}
</li>
))}
</ul>
</div>
);
}
export const meta = {
layout: 'centered',
} satisfies PreviewMeta;
export default UseDelayedLoadingPreview; Previous
Performance tracking & bundle analyzer
Next
useDetectDevice