Empty states, loading skeletons and error states in React

Handle loading, empty and error states in React with Tailwind: layout matched skeletons, three kinds of empty state, retryable errors, toasts and Suspense.

By , MiniDev16 min read

Every data view in React has four states, loading, empty, error and loaded, plus a fifth, refetching, once it has shown data at all. The reliable way to handle them is to decide the state once at the top of the component and return early for each, so the loaded markup never has to guard against missing data.

This guide does that with the free MiniDev UI Skeleton, EmptyState, ErrorState, Spinner, LoadingOverlay and Toast, covers what each one announces to a screen reader, and shows where React 19's Suspense and error boundaries take over. The glossary has the vocabulary: skeleton screen, empty state and toast notification.

One component, every state

Whatever fetches your data, it can be reduced to a status, the data, an error and whether a refetch is in flight. TanStack Query returns exactly that shape; a hand written hook can too. The list below returns early per state and only reaches the real markup with real rows:

app/invoices/invoice-list.tsxtsx
"use client"
import * as React from "react"
import { FileTextIcon } from "lucide-react"
import { EmptyState } from "@/components/ui/empty-state"
import { ErrorState } from "@/components/ui/error-state"
import { LoadingOverlay } from "@/components/ui/loading-overlay"
import { InvoiceSkeleton } from "./invoice-skeleton"

type Query<T> = { status: "pending" | "error" | "success"; data?: T; error?: Error; isFetching: boolean; refetch: () => void }
type Invoice = { id: string; customer: string; amount: number }

export function InvoiceList({ q, filtered, onClearFilters, onCreate }: { q: Query<Invoice[]>; filtered: boolean; onClearFilters: () => void; onCreate: () => void }) {
  if (q.status === "pending") return <InvoiceSkeleton />

  if (q.status === "error") {
    return <ErrorState title="Could not load invoices" description={q.error?.message ?? "Try again in a moment."} onRetry={q.refetch} />
  }

  if (!q.data?.length) {
    return filtered ? (
      <EmptyState title="No invoices match" description="Try a different status or clear the filters." actionLabel="Clear filters" onAction={onClearFilters} />
    ) : (
      <EmptyState
        title="No invoices yet"
        description="Invoices you send appear here with their payment status."
        actionLabel="Create your first invoice"
        onAction={onCreate}
        icon={<FileTextIcon className="size-8" />}
      />
    )
  }

  return (
    <div className="relative">
      {q.isFetching ? <LoadingOverlay label="Refreshing" /> : null}
      <ul className="divide-y divide-border rounded-xl border border-border bg-surface">
        {q.data.map((inv) => (
          <li key={inv.id} className="flex items-center justify-between px-4 py-3 text-sm">
            <span className="text-fg">{inv.customer}</span>
            <span className="tabular-nums text-fg-muted">{inv.amount.toLocaleString("en-US", { style: "currency", currency: "USD" })}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}
  • The order matters: pending, then error, then empty, then loaded. An error with stale data is still an error, and an empty success is not an error.
  • filtered is passed in because the component cannot tell "you have no invoices" from "nothing matches" by looking at an empty array. Those are different messages with different actions.
  • Refetching keeps the rows on screen and overlays them. Replacing a loaded list with a skeleton on every background refresh makes the page blink.

Install

bash
npx shadcn@latest add @minidev/skeleton
npx shadcn@latest add @minidev/empty-state
npx shadcn@latest add @minidev/error-state
npx shadcn@latest add @minidev/spinner
npx shadcn@latest add @minidev/loading-overlay
npx shadcn@latest add @minidev/toast

Import the token stylesheet once (styles.css): the skeleton's shimmer-x sweep and the toast's rise-in are keyframes defined there, and the same file stops all animations under prefers-reduced-motion.

Loading: skeletons that match the layout

Skeleton is a sunken rounded plate with a sheen that sweeps across once every 1.6 seconds; you give it the size with classes. It is aria-hidden, which is correct for a decorative block, so the loading message has to come from a wrapper. Build the skeleton out of the same boxes the real row uses:

app/invoices/invoice-skeleton.tsxtsx
import { Skeleton } from "@/components/ui/skeleton"

export function InvoiceSkeleton({ rows = 5 }: { rows?: number }) {
  return (
    <div role="status" aria-busy="true" className="divide-y divide-border rounded-xl border border-border bg-surface">
      <span className="sr-only">Loading invoices</span>
      {Array.from({ length: rows }, (_, i) => (
        <div key={i} aria-hidden className="flex items-center justify-between px-4 py-3">
          <Skeleton className="h-3.5 w-40" />
          <Skeleton className="h-3.5 w-16" />
        </div>
      ))}
    </div>
  )
}
  • Match heights and widths to the loaded row (py-3, a 3.5 high bar for 14 pixel text), so the swap from skeleton to data does not shift the page.
  • Vary the widths a little across rows if the real content varies; identical bars read as a pattern, not as text.
  • role="status" plus the visually hidden text announces "Loading invoices" once. aria-busy tells assistive technology the region is still changing.
  • SkeletonCard is a ready made card shape (title line, two text lines, a block) for grids of cards. For tables, LoadingTable draws skeleton rows under real headers.

A skeleton that appears for 80 milliseconds and vanishes is worse than nothing. Delay it so fast responses render straight into content, and keep it for a minimum once shown so it does not flicker:

tsx
function useDelayed(active: boolean, delay = 150) {
  const [show, setShow] = React.useState(false)
  React.useEffect(() => {
    if (!active) { setShow(false); return }
    const t = setTimeout(() => setShow(true), delay)
    return () => clearTimeout(t)
  }, [active, delay])
  return show
}

const showSkeleton = useDelayed(q.status === "pending")
if (q.status === "pending") return showSkeleton ? <InvoiceSkeleton /> : <div className="min-h-64" />

The empty min-h-64 placeholder reserves the space during the delay so the layout below does not jump up and back down.

Spinners and overlays for work in place

A spinner says "something is happening here" without promising a shape, which makes it right for a button that is saving, a refetch over existing content, or a panel too small for a skeleton. Spinner is a role="status" element with an aria-label ("Loading" by default) in three sizes. LoadingOverlay places a spinner and a label over its nearest positioned ancestor, dimmed with bg-bg/70:

tsx
<Button disabled={pending}>
  {pending ? <Spinner size="sm" label="Saving" /> : null}
  {pending ? "Saving…" : "Save changes"}
</Button>

<div className="relative">
  {isFetching ? <LoadingOverlay label="Refreshing" /> : null}
  <InvoiceTable rows={rows} />
</div>

The overlay is absolute inset-0, so the parent needs relative or it covers the wrong box. Disable the button while it spins; a spinner inside an enabled button invites a second click. A spinner with no label is an animation, not a status, so keep label meaningful when the default does not fit.

Empty: three different situations

EmptyState takes title, description, actionLabel with onAction, and an optional icon node; without an icon it draws a neutral placeholder tile. The component is the same in every case, and the copy is what changes:

SituationTitleAction
First use, nothing created"No invoices yet"Create the first one, or import
Search with no hits"No results for “acme”"Clear the search; EmptySearch renders this from a query prop
Filters exclude everything"No invoices match"Clear filters, named as such
Permission or plan limit"Reports are on the Team plan"Upgrade, or ask an admin
  • Say what would fill the space and how. "Invoices you send appear here" tells people the page is working and what to do; "No data" tells them nothing.
  • One action, the most likely next step. Two buttons in an empty state usually means the page has not decided what it is for.
  • Keep the empty state the same height class as the loaded content when you can (min-h-64 on both), so toggling filters does not bounce the footer.
  • In tables, EmptyTable takes the same props and sits inside the table frame under the headers.

Errors: say what happened and offer a way out

ErrorState renders a role="alert" card with a title, a description and a Try again button when onRetry is passed. Because it is an alert, it is announced immediately, which is what you want for a failed load and not what you want for a validation message (use FormField errors for those).

  • Write the description for the person, not the log: "We could not reach the billing service. Your invoices are safe." Put the request id in a smaller line if support will ask for it.
  • Retry should actually retry: pass the query's refetch, not a page reload, so the rest of the page keeps its state.
  • An error over stale data: keep the data visible and show a dismissible inline alert above it rather than replacing a working list with an error card.

ServerErrorState, NotFoundState and OfflineState are full page previews with Lumen copy and buttons that do nothing; they take only className. Copy the file, replace the text and wire the buttons. They are references for a 500 page, a 404 page and an offline banner, not drop in components.

Rendering errors are a different class from fetch errors: a chart that throws on bad data takes the whole tree down unless something catches it. React 19 still catches render errors with a class component boundary; wrap each independent panel so one failure does not blank the dashboard:

components/boundary.tsxtsx
"use client"
import * as React from "react"
import { ErrorState } from "@/components/ui/error-state"

type Props = { children: React.ReactNode; title?: string }
type State = { error: Error | null }

export class Boundary extends React.Component<Props, State> {
  state: State = { error: null }
  static getDerivedStateFromError(error: Error): State {
    return { error }
  }
  render() {
    if (this.state.error) {
      return <ErrorState title={this.props.title ?? "This panel ran into a problem"} description="The rest of the page still works." onRetry={() => this.setState({ error: null })} />
    }
    return this.props.children
  }
}

// usage
<Boundary title="Revenue chart failed">
  <RevenueChart />
</Boundary>

Retry here resets the boundary so the child renders again; if the data is the problem it fails again, so log the error in componentDidCatch and send it somewhere. In Next.js, error.tsx is a boundary for a whole route segment and not-found.tsx for 404s, which is where the full page states belong.

Toasts for outcomes, not for states

A toast reports something that finished: the reminder was sent, the export failed. It is the wrong tool for a state the user is looking at; an empty list does not need a toast saying it is empty. Mount Toaster once in the root layout and call toast from anywhere:

tsx
import { toast } from "@/components/ui/toast"

toast.promise(sendReminder(invoice.id), {
  loading: "Sending reminder…",
  success: "Reminder sent to Northwind",
  error: "Could not send the reminder",
})

toast.error("Export failed", {
  description: "The file was larger than 25 MB.",
  action: { label: "Try smaller range", onClick: openRangePicker },
})

toast.promise shows one card that changes tone from loading to success or danger, so the user sees one notification, not three. Danger toasts use role="alert", the others role="status"; both are inside an aria-live="polite" stack. The toast notifications guide covers server actions, undo and stacking.

Streaming with Suspense

In a server rendered app the first loading state is not a client fetch; it is HTML that has not arrived yet. Suspense boundaries show the fallback while a server component awaits data, and the fallback should be the same skeleton the client uses, so the page looks the same whichever way it loads:

app/invoices/page.tsxtsx
import { Suspense } from "react"
import { InvoiceSkeleton } from "./invoice-skeleton"
import { Invoices } from "./invoices"

export default function Page() {
  return (
    <section>
      <h1 className="text-2xl font-medium tracking-[-0.02em] text-fg">Invoices</h1>
      <Suspense fallback={<InvoiceSkeleton />}>
        <Invoices />
      </Suspense>
    </section>
  )
}

Put the boundary around the slow part, not the whole page, so the heading and the filters render immediately. In React 19 a client component can read a promise passed from the server with use(promise), and the nearest Suspense boundary shows the skeleton while it settles. The React Server Components entry explains the split.

Accessibility summary

ComponentRoleWhat is announced
Skeletonnone (aria-hidden)Nothing; wrap in role="status" with hidden text
SpinnerstatusIts label, once
LoadingOverlaystatusIts label, once
EmptyStatenoneRead as normal content: heading, text, button
ErrorStatealertTitle and description immediately
Toaststatus or alert for dangerTitle and description when it appears

Use status for things that are progressing and alert for things that went wrong; an alert interrupts whatever the screen reader was saying. Never put a live region inside a skeleton that re-renders every second, and do not move focus to a loading indicator. Move focus to an error's retry button only when the error replaces the thing the user was interacting with.

Pitfalls

  • A generic skeleton for every page. Five grey bars that look nothing like the content make the swap jarring. Build one skeleton per layout, from the layout's own classes.
  • Showing the empty state while loading. A flash of "No invoices yet" before the data arrives scares people. Check pending before checking length.
  • Treating a 404 as an error card. A missing record is a not found page with a way back, not a retry button.
  • Toasting validation errors. They vanish before the user finds the field. Inline errors next to the input, every time.
  • Spinner forever. Every pending state needs a timeout path: after 10 seconds show an error with retry rather than spinning until the heat death of the session.

Components used in this guide

All of these are free and MIT licensed, and the rest of the feedback category covers banners, inline alerts and progress. The list above is the body of a page from the Next.js SaaS dashboard guide, and its table variant is in the TanStack data table guide. If you want the whole product built, the MiniDev studio builds on this kit.

Empty StateuiDashed empty state panel with an icon slot, title, description and an optional call to action button. Use it for first run screens and empty lists in React.npx shadcn@latest add @minidev/empty-stateSkeletonuiTailwind skeleton loader with a sunken plate and a soft sheen that sweeps every 1.6s, plus a SkeletonCard preset with title, text lines and a media block.npx shadcn@latest add @minidev/skeletonError StateuiReact error state panel with an alert role, a title, explanation and an optional Try again button that calls your retry handler. For failed fetches.npx shadcn@latest add @minidev/error-stateSpinneruiLoading spinner in sm, default and lg sizes with role status and an accessible Loading label. Drop it into buttons, cards or full page states in a Tailwind app.npx shadcn@latest add @minidev/spinnerToastuiToast notifications with a toast() API for success, error, warning and promise states. The Toaster stacks cards, fans them out on hover and pauses timers.npx shadcn@latest add @minidev/toast
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit

Components used in this guide

Frequently asked questions

Skeleton or spinner: which should I use?

A skeleton when you know the shape of what is coming and the wait is long enough to notice (a list, a card grid, a profile). A spinner for work in place where no shape applies: a saving button, a refetch over existing data, a small panel.

How do I stop the loading skeleton from flickering?

Delay it by about 150 milliseconds so fast responses render straight to content, reserve the space with a min height during the delay, and keep loaded data on screen during background refetches with an overlay instead of swapping back to the skeleton.

Should empty states and error states be announced to screen readers?

An empty state is ordinary content, read in order like any heading and paragraph. A failed load is an alert: ErrorState has role="alert" so it is announced immediately. Loading indicators use role="status" and announce once, never on every frame.

More guides