@k8ordo/router

Errors and loading states

What to show when a page throws while rendering, and what to show until a page is ready, are written into the route table’s objects. This page covers error and loading, and how to wait for pages split out with React.lazy.

On this page

Show an error

Give an object with children an error, and when a page below it throws while rendering, the error component renders in its place. It renders where the layout puts <Outlet />, so the layout itself stays on screen.

src/routes.ts
export const routes = defineRoutes({
  '/products': {
    layout: ProductsLayout,
    error: ProductsError,
    children: {
      '/': ProductList,
      '/:id': ProductPage,
    },
  },
});
src/products-error.tsx
import type { ErrorProps } from '@k8ordo/router';

export function ProductsError({ error, reset }: ErrorProps) {
  const message =
    error instanceof Error ? error.message : 'Something went wrong';

  return (
    <div role="alert">
      <p>{message}</p>
      <button onClick={reset} type="button">
        Try again
      </button>
    </div>
  );
}

The error component receives what was thrown as error, and a function to render again as reset. Anything can be thrown, so error is typed unknown.

Calling reset() renders the page below again, in place. If it throws again, the error component comes back.

The error goes away once you move to another page, at the moment that page is on screen. The layouts inside the object are not recreated, and keep their state.

An update that changes only the query string is not a page change, though, so the error stays.

Information

Note

A layout that throws is not caught by its own object’s error, which renders inside the layout. The error reaches the error of an object further out, and leaves <Router> when there is none.

Show a loading state

loading names the component to show while a page below suspends. It receives no props.

src/routes.ts
export const routes = defineRoutes({
  '/products': {
    layout: ProductsLayout,
    error: ProductsError,
    loading: ProductsLoading,
    children: {
      '/': ProductList,
      '/:id': ProductPage,
    },
  },
});
src/products-loading.tsx
export function ProductsLoading() {
  return <p role="status">Loading products…</p>;
}

A <Suspense> goes where loading is written, with the loading component as its fallback. With an error beside it, the <Suspense> sits inside where errors are caught.

The fallback shows when the app is first opened here, and when you arrive from a page outside this object.

Moving between pages already inside the object shows no fallback: the previous page stays until the next one is ready. To show that wait, use usePendingPathname, covered in “Find where you are”.

Load a page’s code on demand

A page component can be wrapped in React.lazy before it goes in the route table. Its code then loads the first time the page renders.

src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { lazy } from 'react';

const Settings = lazy(() => import('./pages/settings'));

export const routes = defineRoutes({
  '/': {
    layout: Shell,
    loading: PageLoading,
    children: {
      '/': Home,
      '/settings': Settings,
    },
  },
});

Until the code arrives, the fallback of the nearest <Suspense> above shows. Write a loading, or wrap <Outlet /> in a <Suspense> inside a layout, so there is somewhere to fall back to.

The fallback shows at the same moments as loading. In the example, the / object wraps every page, so opening /settings directly shows the fallback, while moving there from the home page keeps the home page on screen until the code arrives.

Warning

Pitfall

An object with an error also wraps what is below in a <Suspense> whose fallback is null, so that a part that throws during a server render is left for the browser. A React.lazy page that suspends below it therefore never reaches a layout’s <Suspense> above, and nothing shows. To show a fallback, give the same object a loading, or put a <Suspense> further down.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2