HextaUI

useDelayedLoading

処理が実際に遅いときだけ読み込み状態を表示し、ちらつかないよう十分な時間表示し続けます。

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

フックと、その依存関係をプロジェクトに追加します。

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

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

生の loading フラグを渡し、返された真偽値から描画します。ウォーム接続でのほとんどのリクエストは150ms未満で終わります。そのためにスピナーを表示すると、何も表示しないより悪くなります。1〜2フレームだけ点滅し、進行ではなく不具合に見えるためです。

このフックは2つのルールを適用します。delay が経つまでは何も表示しないため、それより早く終わる処理は読み込み状態を表示しません。インジケーターが表示されたら最低でも minDuration は表示され続けるため、数フレームのうちに現れて消えることはありません。

処理にかかる時間説明
80ms何も表示されません。
250ms150msで表示され、最低400msの猶予により550msまで保持されます。
900ms150msで表示され、処理が終わるとすぐに非表示になります。

400msの最小時間は、意図的な状態として認識されるのに十分な長さで、誰かを待たせない程度に短い時間です。

  • インジケーターがまだ表示されている間に loading が再びオンになった場合は、そのまま表示され続けます。非表示にして再表示することはありません。
  • 入力が変わったときやコンポーネントがアンマウントされたときにタイマーはクリアされるので、消えた後に状態が更新されることはありません。
  • サーバー上と最初のレンダリング中は false を返すため、ハイドレーションの不一致を生むことはありません。

スケルトン

スケルトンはコンテンツを置き換えるため、一瞬の点滅はスピナーよりさらに目障りです。ここでは最初の読み込みが遅くスケルトンが表示されます。以降の読み込みはキャッシュから行われ、表示されません。

"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,
})

スケルトンやオーバーレイのように画面の広い範囲を覆うインジケーターでは、delay を大きくします。支払いのように、どんな待ち時間でも反応が必要な操作では、0に近づけます。minDuration は約300ms以上に保ってください。

  • <Spinner loading={...} /> と <Button loading> はすでにこのタイミングを使っています。それ以外のものを描画するときにこのフックを使ってください。
  • 例のように、インジケーターが占める空間を確保しておくと、表示されたときにレイアウトがずれません。
  • aria-busy やステータスメッセージと組み合わせてください。このフックが決めるのは、視覚的に何を表示するかだけです。
プロパティ型デフォルト
loading処理がいま進行中かどうか。
boolean–
options.delay読み込み状態を表示するまでの待機時間(ミリ秒)。
number150
options.minDuration読み込み状態が表示された後、最低限表示され続けるミリ秒。
number400
戻り値説明
boolean読み込み状態を表示するかどうか。サーバーでは常に false です。

loading prop を通じた Spinner。