Loading & Error UI
Two optional files next to a page.jsx give the pages beneath their folder a loading view and an error view.
app/
├─ layout.jsx
├─ error.jsx # catches failures anywhere in the app
└─ dashboard/
├─ layout.jsx
├─ loading.jsx # shown while a dashboard page loads
├─ error.jsx # shown when a dashboard page fails
├─ page.jsx
└─ settings/page.jsx # uses dashboard's loading.jsx and error.jsxloading.jsx and error.jsx ship in the next releases of @opentf/afw, @opentf/afw-build and @opentf/vite-plugin-afw.
Which file applies
The nearest one wins. The router looks in the page's folder, then each parent up to
app/.Inside the layouts. Both replace only the page, so the layouts around it (navbar, sidebar) stay on screen and keep working.
Layouts use the one above. A broken layout shows the
error.jsxabove it: see Layout errors.Not pages.
loading.jsxanderror.jsxnever become routes of their own.JSX or TSX only. A
loading.mdxorerror.mdis a build error.
loading.jsx
// app/dashboard/loading.jsx
export default function Loading() {
return <DashboardSkeleton />;
}It shows in two situations:
While navigating to the page. When the page's loader data or code takes longer than 150 ms, the skeleton replaces the old page inside the layouts. A faster navigation never shows it.
While the page's resources load. It is the
<Pending>fallback for everyresource()the page reads outside a section boundary.
It receives { params, query } for the route being loaded.
| Rendering | First load | Client navigation |
|---|---|---|
| Client-only app | Shown while the data and resources load | Shown |
| Server rendering | Loader data is already in the HTML; not shown | Shown |
| Static generation | Loader data is baked into the HTML; not shown | Shown while the static __data.json loads |
error.jsx
// app/dashboard/error.jsx
export default function DashboardError({ error, retry }) {
return (
<div role="alert">
<p>Couldn't load the dashboard: {error.message}</p>
<button onclick={retry}>Try again</button>
</div>
);
}| Prop | What it is |
|---|---|
error | What failed. After a server render it has a status, and in production a generic message. |
retry | Loads the page again: reruns its loader and rebuilds it, without a full reload. |
params, query | The route's params and query. |
It replaces the page when:
| Failure | Where |
|---|---|
| The loader throws | Client navigation, or a server render (sent with HTTP 500) |
| The page throws while it builds | Client |
A component inside the page throws, with no <ErrorBoundary> around it | Client |
A resource's first load fails under loading.jsx, or under a <Pending> without an error prop | Client |
Without an
error.jsx, a failed loader leavesrouter.dataundefined and the page renders, as before.notFound()still renders404.jsxwith HTTP 404.Redirects and refusals (a thrown
Response) are unchanged: see Loader Errors & Redirects.Production messages. A server-rendered error page shows "Something went wrong" in production, so a loader's internals never reach the browser. Development shows the real message.
Static generation. A loader that throws at build time is reported and that page is skipped, as before. An error page is never baked into a static file.
Layout errors
A layout can't show its own error inside itself, because it is the part that broke. Its errors go to the nearest error.jsx above the layout, shown inside the layouts above it.
app/
├─ layout.jsx # root shell: stays, with its navbar
├─ error.jsx # shows when the dashboard layout breaks
└─ dashboard/
├─ layout.jsx # throws, or its layout.loader.js fails
├─ error.jsx # only for the dashboard's pages
└─ page.jsx| Failure | What shows |
|---|---|
dashboard/layout.jsx throws while it builds | app/error.jsx, inside the root layout |
A component in the dashboard layout throws, with no <ErrorBoundary> around it | app/error.jsx, inside the root layout |
dashboard/layout.loader.js fails | app/error.jsx, inside the root layout; HTTP 500 on a server render |
| A dashboard page fails | dashboard/error.jsx, inside the dashboard layout |
Retry reruns the loaders and rebuilds from the broken layout down.
The root layout has nothing above it: its errors are reported, not caught. Keep it simple, or wrap risky parts of it in an
<ErrorBoundary>.
Dashboards: sections inside the page
The route files are the page-level default. A page whose sections load independently gives each section its own boundary, so they fill in and fail separately:
// app/dashboard/page.jsx
import { Pending } from "@opentf/afw";
export default function Dashboard() {
return (
<div class="grid">
<Pending fallback={<ChartSkeleton />} error={(e, retry) => <SectionError title="Revenue" onRetry={retry} />}>
<RevenueChart />
</Pending>
<Pending fallback={<TableSkeleton />} error={(e, retry) => <SectionError title="Orders" onRetry={retry} />}>
<RecentOrders />
</Pending>
</div>
);
}Each resource waits on its nearest boundary. A section's resources hold that section, not
loading.jsx.The page shows at once when every section has a boundary:
loading.jsxthen only covers the navigation and the loader data.A section's
errorshows its failure in place; the other sections keep working.Anything no section catches (the loader, the page itself, a section without
error) goes toerror.jsx.
Compared with other frameworks
| AFW | Next.js | React Router | TanStack Start | SvelteKit | |
|---|---|---|---|---|---|
| Loading UI per route | loading.jsx | loading.js | useNavigation() in a layout | pendingComponent | $navigating |
| Error UI per route | error.jsx | error.js | ErrorBoundary export | errorComponent | +error.svelte |
| Retry | retry() | reset() | Revalidate | router.invalidate() | invalidate() |
| Per-section loading and error | <Pending fallback error> | <Suspense> + error boundary | <Suspense> + <Await errorElement> | <Suspense> + <CatchBoundary> | <svelte:boundary> |