Loading & Error UI

Two optional files next to a page.jsx give the pages beneath their folder a loading view and an error view.

TEXT
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.jsx
New in the next release

loading.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.jsx above it: see Layout errors.

  • Not pages. loading.jsx and error.jsx never become routes of their own.

  • JSX or TSX only. A loading.mdx or error.md is a build error.

loading.jsx

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 every resource() the page reads outside a section boundary.

It receives { params, query } for the route being loaded.

RenderingFirst loadClient navigation
Client-only appShown while the data and resources loadShown
Server renderingLoader data is already in the HTML; not shownShown
Static generationLoader data is baked into the HTML; not shownShown while the static __data.json loads

error.jsx

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>
  );
}
PropWhat it is
errorWhat failed. After a server render it has a status, and in production a generic message.
retryLoads the page again: reruns its loader and rebuilds it, without a full reload.
params, queryThe route's params and query.

It replaces the page when:

FailureWhere
The loader throwsClient navigation, or a server render (sent with HTTP 500)
The page throws while it buildsClient
A component inside the page throws, with no <ErrorBoundary> around itClient
A resource's first load fails under loading.jsx, or under a <Pending> without an error propClient
  • Without an error.jsx, a failed loader leaves router.data undefined and the page renders, as before.

  • notFound() still renders 404.jsx with 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.

TEXT
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
FailureWhat shows
dashboard/layout.jsx throws while it buildsapp/error.jsx, inside the root layout
A component in the dashboard layout throws, with no <ErrorBoundary> around itapp/error.jsx, inside the root layout
dashboard/layout.loader.js failsapp/error.jsx, inside the root layout; HTTP 500 on a server render
A dashboard page failsdashboard/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:

JSX
// 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.jsx then only covers the navigation and the loader data.

  • A section's error shows its failure in place; the other sections keep working.

  • Anything no section catches (the loader, the page itself, a section without error) goes to error.jsx.

Compared with other frameworks

AFWNext.jsReact RouterTanStack StartSvelteKit
Loading UI per routeloading.jsxloading.jsuseNavigation() in a layoutpendingComponent$navigating
Error UI per routeerror.jsxerror.jsErrorBoundary exporterrorComponent+error.svelte
Retryretry()reset()Revalidaterouter.invalidate()invalidate()
Per-section loading and error<Pending fallback error><Suspense> + error boundary<Suspense> + <Await errorElement><Suspense> + <CatchBoundary><svelte:boundary>
Last updated on
Edit this page