State view
One layout for a region's loading, empty and error states, and an error boundary that reports errors, recovers and never shows error details to users.
View as Markdown for coding assistants, or see llms.txt.
- Stable
- Group: Feedback
- Added in @azmr/ui 0.12.0
Usage
import { StateView } from "@azmr/ui/state-view"; // Server Component
import { ErrorBoundary } from "@azmr/ui/error-boundary"; // "use client"No invoices yet
Invoices you create appear here.
Invoices couldn't load
Check your connection and try again.
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-md border">
<StateView state="loading" title="Loading invoices…" delay={0} titleAs="h3" />
</div>
<div className="rounded-md border">
<StateView
state="empty"
title="No invoices yet"
description="Invoices you create appear here."
action={<Button size="sm">Create invoice</Button>}
titleAs="h3"
/>
</div>
<div className="rounded-md border">
<StateView
state="error"
title="Invoices couldn't load"
description="Check your connection and try again."
action={
<Button size="sm" variant="outline">
Try again
</Button>
}
titleAs="h3"
/>
</div>
</div>Every list, table and page has the same three extra states: still loading, nothing to show, and failed. StateView gives them one layout (icon, title, a sentence, an action), so they look and read the same everywhere. It is a Server Component, so it works in loading.tsx, error.tsx, Suspense fallbacks and empty tables.
state | Announced as | Default title | Default icon |
|---|---|---|---|
loading | a polite status (it is Loading), after 200 ms | Loading… | the spinner, or your logo |
empty | plain content | Nothing here yet | inbox |
error | an alert | Something went wrong | alert, in the destructive colour |
variant="page" fills the content area and section (default) fills its container. titleAs picks the heading level (h2 by default) so it fits the page's outline; use h1 when the state is the whole page, such as a 404 page. icon={null} drops the icon, and action takes any buttons or links.
Page loader
A full-page loader with your logo is <StateView state="loading" variant="page" logo={…} />, the same as <Loading variant="page" logo={…} />. The ring spins around the logo while the logo stays still.
Error boundary
Sales this month: $12,480.00
Errors reported: 0
<div className="flex max-w-md flex-col gap-4">
<Button variant="destructive" className="self-start" onClick={() => setBroken(true)}>
Break the report
</Button>
<div className="rounded-md border">
<ErrorBoundary
// Your reporting hook: Sentry.captureException, posthog.captureException...
onError={() => setReported((n) => n + 1)}
// Try again re-renders the children, so fix the cause first.
onReset={() => setBroken(false)}
fallbackProps={{ titleAs: "h3", title: "The report couldn't load" }}
>
<Report broken={broken} />
</ErrorBoundary>
</div>
<p className="text-sm text-muted-foreground">Errors reported: {reported}</p>
</div>ErrorBoundary catches errors thrown while its children render and shows a fallback with Try again, instead of a blank page.
- Reporting:
onError(error, info)is called once per caught error, with the component stack. Send it to Sentry, PostHog or your own logger. The boundary has no reporting service built in. - Recovery: Try again calls
reset, which renders the children again (so fix the cause first, inonReset).resetKeys={[id]}resets by itself when a route or record changes. - No error details by default: the default fallback says "Something went wrong" and never shows
error.message, which can carry an email address, a record id or a server path. Customise the text withfallbackProps, or pass your ownfallback, which receives{ error, reset }. - What it does not catch: errors in event handlers, timers and promises are not render errors. Catch them where they happen and show a toast or set state.
In Next.js
Next.js has its own error boundary, the error.tsx file. Use StateView as its UI. Since Next.js 16.2 it receives retry, which fetches the segment again; reset only re-renders it.
"use client";
import { Button } from "@azmr/ui/button";
import { StateView } from "@azmr/ui/state-view";
import { useEffect } from "react";
export default function InvoicesError({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
useEffect(() => {
reportError(error); // your reporting hook; error.digest matches the server logs
}, [error]);
return (
<StateView
state="error"
variant="page"
title="Invoices couldn't load"
description="Try again, or come back later."
action={<Button onClick={() => retry()}>Try again</Button>}
/>
);
}import { StateView } from "@azmr/ui/state-view";
export default function InvoicesLoading() {
return <StateView state="loading" variant="page" title="Loading invoices…" />;
}Use ErrorBoundary for a part of a page (a chart, a widget) that should fail on its own. In Next.js pass rethrow={unstable_rethrow} from next/navigation: redirect() and notFound() work by throwing, and rethrow lets them through to Next.js instead of showing them as errors. They are not passed to onError either.
"use client";
import { ErrorBoundary } from "@azmr/ui/error-boundary";
import { unstable_rethrow } from "next/navigation";
<ErrorBoundary rethrow={unstable_rethrow} onError={reportError} fallbackProps={{ title: "This chart couldn't load" }}>
<RevenueChart />
</ErrorBoundary>Props
StateView
Prop
Type
ErrorBoundary
Prop
Type
Accessibility
- An error is a
role="alert", so it is announced when it appears; loading is a polite status; empty is plain content, found by reading the page. - The title is a real heading (
titleAs), so screen reader users can jump to it. - Icons are decorative (
aria-hidden); the title says what happened. - After an error, Try again is an ordinary button: keyboard users reach it with Tab.