hono-preact

Loading States

By default, a page renders nothing while its loader is fetching. Pass a fallback to loader.View(...) (or to a loader.Boundary) to show a loading UI while a route's loader resolves during client-side navigation.

Basic usage

// src/pages/movies.tsx
import { definePage } from 'hono-preact';
import { serverLoaders } from './movies.server.js';

const moviesLoader = serverLoaders.default;

const MoviesView = moviesLoader.View(
  ({ data }) => (
    <ul>
      {data.movies.results.map((m) => (
        <li key={m.id}>{m.title}</li>
      ))}
    </ul>
  ),
  { fallback: <p>Loading…</p> }
);

export default definePage(MoviesView);
// src/routes.ts
{
  path: '/movies',
  view: () => import('./pages/movies.js'),
  server: () => import('./pages/movies.server.js'),
}

fallback is rendered by the Suspense boundary .View() installs around the loader fetch. It only appears on client-side navigation; SSR and first-load hydration read from preloaded data and render immediately.

When fallback shows

NavigationFallback shown?
SSR (first load)No. Data is preloaded into the HTML.
HydrationNo. Client reads from data-loader attribute.
Client-side nav (cache miss)Yes, until the loader resolves.
Client-side nav (cache hit)No. Cached data renders immediately.

Using a skeleton

Any Preact element works as a fallback, including a layout-matching skeleton:

const MoviesSkeleton = () => (
  <ul>
    {Array.from({ length: 5 }).map((_, i) => (
      <li key={i} class="h-6 w-48 animate-pulse bg-gray-200 rounded" />
    ))}
  </ul>
);

const MoviesView = moviesLoader.View(
  ({ data }) => (
    <ul>
      {data.movies.results.map((m) => (
        <li key={m.id}>{m.title}</li>
      ))}
    </ul>
  ),
  { fallback: <MoviesSkeleton /> }
);

Error fallback

If the loader rejects, the boundary renders errorFallback instead of unwinding the page tree. Pass it alongside fallback in the same .View() options. The fallback may be an element or a function that receives the error and a reset() callback:

const MoviesView = moviesLoader.View(
  ({ data }) => (
    <ul>
      {data.movies.results.map((m) => (
        <li key={m.id}>{m.title}</li>
      ))}
    </ul>
  ),
  {
    fallback: <p>Loading…</p>,
    errorFallback: (err, reset) => (
      <div role="alert">
        <p>Couldn't load movies: {err.message}</p>
        <button onClick={reset}>Retry</button>
      </div>
    ),
  }
);

Calling reset() clears the boundary so the next render attempt re-enters the loader. For runtime invalidation of cached data from elsewhere on the page, use useReload or cross-page invalidation.

.View() options reference

Both options are passed as the second argument to loader.View(render, opts):

OptionTypeDescription
fallbackComponentChildrenShown while the loader is pending.
errorFallbackComponentChildren | ((err, reset) => ComponentChildren)Shown when the loader errors; the function form receives reset.

Page-level fallbacks

definePage accepts a page-level errorFallback that catches errors from the rest of the page tree (e.g. a render-time throw outside any loader boundary):

export default definePage(MoviesView, {
  errorFallback: (err) => <p>Something went wrong: {err.message}</p>,
});

Loader-specific loading and error UI lives on .View() / .Boundary. The page-level errorFallback is the outer safety net.

See also