Agents (llms.txt)
Octocat

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 booleanshowLoading — 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.

Naive (raw isLoading)
idle
useDelayedLoading
idle
  • 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