HextaUI

useDelayedLoading

Shows a loading state only when work is actually slow, then keeps it up long enough that it never flickers.

loading
useDelayedLoading(loading)
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { useDelayedLoading } from "@/hooks/use-delayed-loading"

function Lane({ label, loading }: { label: string; loading: boolean }) {
  return (
    <div className="flex h-9 items-center justify-between gap-4 rounded-lg bg-muted px-3 text-sm">
      <span className="text-muted-foreground">{label}</span>
      <span className="flex size-4 items-center justify-center">
        {loading ? <Spinner /> : null}
      </span>
    </div>
  )
}

export function UseDelayedLoadingDemo() {
  const [loading, setLoading] = React.useState(false)
  const visible = useDelayedLoading(loading)
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  const load = (ms: number) => {
    clearTimeout(timer.current)
    setLoading(true)
    timer.current = setTimeout(() => setLoading(false), ms)
  }

  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Lane label="loading" loading={loading} />
        <Lane label="useDelayedLoading(loading)" loading={visible} />
      </div>
      <div className="flex flex-wrap justify-center gap-2">
        <Button variant="outline" size="sm" onClick={() => load(80)}>
          80ms
        </Button>
        <Button variant="outline" size="sm" onClick={() => load(220)}>
          220ms
        </Button>
        <Button variant="outline" size="sm" onClick={() => load(1500)}>
          1.5s
        </Button>
      </div>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/use-delayed-loading.json

Adds the hook and anything it depends on to your project.

import { useDelayedLoading } from "@/hooks/use-delayed-loading"
const { data, isFetching } = useQuery(query)
const showSpinner = useDelayedLoading(isFetching)

return showSpinner ? <Spinner /> : <Results data={data} />

Pass the raw loading flag and render from the boolean it returns. Most requests on a warm connection finish in under 150ms. Showing a spinner for those is worse than showing nothing: it flashes for a frame or two and reads as a glitch, not as progress.

The hook applies two rules. It waits for delay before showing anything, so work that finishes sooner never shows a loading state. Once the indicator is visible, it stays for at least minDuration, so it can't appear and vanish within a few frames.

Work takesDescription
80msNothing is shown.
250msShown at 150ms and held until 550ms, the 400ms minimum.
900msShown at 150ms and hidden as soon as work ends.

The 400ms minimum is long enough to register as a deliberate state and short enough not to slow anyone down.

  • If loading turns back on while the indicator is still visible, it simply stays visible. There's no hide and show again.
  • Timers are cleared when the inputs change or the component unmounts, so nothing updates state after it's gone.
  • On the server and during the first render it returns false, so it never adds a hydration mismatch.

Skeletons

Skeletons replace content, so a flash is even more jarring than with a spinner. Here the first load is slow and shows the skeleton. Later loads come from a cache and never do.

  • Ada Lovelace
  • Grace Hopper
  • Alan Turing
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { useDelayedLoading } from "@/hooks/use-delayed-loading"

const people = ["Ada Lovelace", "Grace Hopper", "Alan Turing"]

export function UseDelayedLoadingSkeleton() {
  const [loading, setLoading] = React.useState(false)
  const [cached, setCached] = React.useState(false)
  const showSkeleton = useDelayedLoading(loading, {
    delay: 200,
    minDuration: 500,
  })

  const refresh = () => {
    setLoading(true)
    setTimeout(
      () => {
        setLoading(false)
        setCached(true)
      },
      cached ? 60 : 1200
    )
  }

  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <ul className="flex flex-col gap-3">
        {people.map((name) => (
          <li key={name} className="flex h-5 items-center text-sm">
            {showSkeleton ? <Skeleton className="h-3 w-32" /> : name}
          </li>
        ))}
      </ul>
      <Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
        {cached ? "Refresh (cached)" : "Refresh (slow)"}
      </Button>
    </div>
  )
}
const showSkeleton = useDelayedLoading(isLoading, {
  delay: 300,
  minDuration: 600,
})

Raise delay for indicators that cover a lot of the screen, like skeletons or overlays. Lower it toward 0 for actions where any wait needs acknowledging, like a payment. Keep minDuration above roughly 300ms.

  • <Spinner loading={...} /> and <Button loading> already use these timings. Reach for the hook when you render something else.
  • Keep the space the indicator will take, as the examples do, so the layout doesn't shift when it appears.
  • Pair it with an aria-busy or a status message. The hook only decides what to show visually.
PropTypeDefault
loadingWhether the work is in progress right now.
boolean–
options.delayMilliseconds to wait before showing the loading state.
number150
options.minDurationMinimum milliseconds the loading state stays visible once shown.
number400
ReturnsDescription
booleanWhether to show the loading state. Always false on the server.

Spinner through its loading prop.