Docs

Switch guides from the menu links on larger screens.

Overview

Framework docs

React

Reference docs for @waitency/react. Start here if you are shipping Waitency in a React 18+ application.

Installation

npmnpm install @waitency/react
yarnyarn add @waitency/react
pnpmpnpm add @waitency/react

Works with Next.js, Vite, Create React App, Remix, and any React 18+ setup.

Basic usage

Import useWaitency and defineTimeline, define your phases, then call start() and resolve(). The hook returns a phase object whose name you switch on to render the right UI.

MyComponent.tsx
import { useState } from 'react'
import { useWaitency, defineTimeline } from '@waitency/react'

type Profile = { id: string; name: string }

const phases = defineTimeline([
  { name: 'silent', duration: 50 },
  { name: 'spinner', duration: 950 },
  { name: 'message' },
])

function MyComponent() {
  const [profile, setProfile] = useState<Profile | null>(null)
  const { phase, isPending, start, resolve } = useWaitency<Profile>({ phases })

  async function handleLoad() {
    start()
    const nextProfile = await fetchData()
    setProfile(nextProfile)
    resolve(nextProfile)
  }

  if (!profile && phase?.name === 'spinner') return <Spinner />
  if (!profile && phase?.name === 'message') return <Spinner message="Taking a moment…" />

  return <YourContent data={profile} isLoading={isPending} onLoad={handleLoad} />
}

Custom timelines

Each component can have its own timing. Use shorter timelines for fast interactions and longer timelines for data-heavy screens.

DataTable.tsx
import { useState } from 'react'
import { useWaitency, defineTimeline } from '@waitency/react'

type Row = { id: string; name: string }

const phases = defineTimeline([
  { name: 'silent', duration: 80 },
  { name: 'spinner', duration: 1920 },
  { name: 'message' },
])

function DataTable({ userId }: { userId: string }) {
  const [rows, setRows] = useState<Row[]>([])
  const { phase, start, resolve } = useWaitency<Row[]>({ phases })

  async function load() {
    start()
    const nextRows = await fetch(`/api/users/${userId}`).then((response) => response.json())
    setRows(nextRows)
    resolve(nextRows)
  }

  if (!rows.length && phase?.name === 'spinner') return <TableSkeleton rows={6} />
  if (!rows.length && phase?.name === 'message') return <TableSkeleton rows={6} message="Fetching records…" />

  return <Table data={rows} onRefresh={load} />
}

Minimum display

minDisplay ensures a phase stays visible for at least the specified duration, even if resolve() fires early. Use this to prevent spinner flash when a request finishes just after a loading state appears.

min-display.ts
import { defineTimeline } from '@waitency/react'

const phases = defineTimeline([
  { name: 'silent', duration: 50 },
  { name: 'spinner', duration: 950, minDisplay: 300 },
  { name: 'message', minDisplay: 500 },
])

// If resolve() fires at 200ms, the spinner still
// stays visible for at least 300ms to avoid flash.

Error handling

Call reject(error) instead of resolve() when an operation fails. The hook moves to the rejected status and exposes the error via error and isRejected. Call reset() before retrying to return to idle.

UserProfile.tsx
import { useState } from 'react'
import { useWaitency, defineTimeline } from '@waitency/react'

const phases = defineTimeline([
  { name: 'silent', duration: 50 },
  { name: 'spinner', duration: 950 },
  { name: 'message' },
])

function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState(null)
  const { phase, isRejected, error, start, resolve, reject, reset } = useWaitency({ phases })

  async function load() {
    start()
    try {
      const data = await fetchUser(userId)
      setProfile(data)
      resolve(data)
    } catch (err) {
      reject(err)
    }
  }

  if (isRejected) {
    return (
      <div>
        <p>Something went wrong: {error?.message}</p>
        <button onClick={() => { reset(); load() }}>Retry</button>
      </div>
    )
  }

  if (phase?.name === 'spinner') return <Spinner />
  if (phase?.name === 'message') return <Message text="Hang tight…" />
  return <ProfileCard data={profile} onLoad={load} />
}

TypeScript

Waitency ships its own type definitions. phase, status, and the phase config are all typed.

types.ts
import type { WaitencyPhase, WaitencyStatus } from '@waitency/react'
import { defineTimeline, useWaitency } from '@waitency/react'

const phases: WaitencyPhase[] = defineTimeline([
  { name: 'silent', duration: 50 },
  { name: 'spinner', duration: 950 },
  { name: 'message' },
])

const {
  status,
  phase,
  elapsed,
  start,
  resolve,
  reject,
  reset,
} = useWaitency({ phases })

// status  -> WaitencyStatus
// phase   -> WaitencyPhase | null
// elapsed -> number

API reference

defineTimeline(phases)

Creates a validated phase array with absolute timing boundaries. Each phase object accepts:

PropertyTypeDefaultDescription
namestringrequiredPhase identifier returned via phase.name.
durationnumberInfinityTime in ms before advancing to the next phase.
minDisplaynumber0Minimum time in ms this phase remains visible after resolve.

useWaitency(options)

Options:

OptionTypeDescription
phasesWaitencyPhase[]Timeline produced by defineTimeline().
componentIdstring?Registers the hook with the global registry for Pro tooling.
onPhaseChange(phase, elapsed) => voidCalled each time the active phase changes.
onResolve(data, elapsed) => voidCalled when resolve() settles the operation.
onReject(error, elapsed) => voidCalled when reject() settles the operation with an error.
onCancel(elapsed) => voidCalled when cancel() aborts the timeline.

Return value:

PropertyTypeDescription
stateWaitencyStateFull state snapshot returned by the underlying machine.
statusWaitencyStatus'idle' | 'pending' | 'resolved' | 'rejected'.
phaseWaitencyPhase | nullCurrent phase object while the timeline is active.
settledPhaseWaitencyPhase | nullPhase that was active when the request settled.
elapsednumberMilliseconds since start() was called.
dataTData | undefinedData passed to resolve(data).
errorTError | undefinedError passed to reject(error).
isIdlebooleanTrue when the hook has not started.
isPendingbooleanTrue while the operation is in progress.
isResolvedbooleanTrue after resolve() completes.
isRejectedbooleanTrue after reject() completes.
start() => voidBegin the timeline.
resolve(data?) => voidMark the operation as successful.
reject(error?) => voidMark the operation as failed.
cancel() => voidAbort the operation and return to idle.
reset() => voidReset state back to idle manually.