Loading States#
Show a loading UI during client-side navigation by passing a fallback to loader.View(), so users see a skeleton or spinner instead of a blank page while their data loads.
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#
| Navigation | Fallback shown? |
|---|---|
| SSR (first load) | No. Data is preloaded into the HTML. |
| Hydration | No. 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):
| Option | Type | Description |
|---|---|---|
fallback | ComponentChildren | Shown while the loader is pending. |
errorFallback | ComponentChildren | ((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#
- Server Loaders: where
.View()comes from. - Streaming: fallback behavior for streaming loaders.
- Middleware: the
usebinding. - Project Structure: the full
definePagebindings list (includingWrapper).