Loading States#
Show a loading UI during client-side navigation by switching on the LoaderState the loader.View() render function receives. It is a discriminated union with four status variants: loading (no data yet), success, revalidating (a background refresh with the prior data still present), and error. Match on status and render your own affordance for each; the framework supplies the state, not the UI. For a simple loading-or-content split you can skip the status check and read data directly (it is undefined only while loading); reach for status when you need to tell revalidating or error apart.
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 }) => {
// `data` is present in every arm but the cold `loading` one, so a truthy
// check doubles as the loading guard and narrows `data` to the value type.
if (!data) return <p>Loading...</p>;
return (
<ul>
{data.movies.results.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
);
});
export default definePage(MoviesView);
// src/routes.ts
{
path: '/movies',
view: () => import('./pages/movies.js'),
}
The loading status appears only on a client-side navigation with no cached value; SSR and first-load hydration render with data already present, so the initial paint is success, never loading.
When each status appears#
| Navigation | status on entry |
|---|---|
| SSR (first load) | success. Data is preloaded into the HTML. |
| Hydration | success. Client reads from the data-loader attribute. |
| Client-side nav (cache miss) | loading, until the loader resolves (then success). |
| Client-side nav (cache hit) | success. Cached data renders immediately. |
| Reload (stale-while-revalidate) | revalidating. data retains the previous value. |
Using a skeleton#
Return a skeleton component while the cold load is in flight (data still absent):
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 }) =>
data ? (
<ul>
{data.movies.results.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
) : (
<MoviesSkeleton />
)
);
Stale-while-revalidate#
During a reload the status is revalidating: a refresh is in flight, but data still holds the previous value. Render the cold-load placeholder only for loading, and show a subtle indicator for revalidating so the current content stays visible:
const MoviesView = moviesLoader.View(({ status, data }) => {
if (!data) return <MoviesSkeleton />;
return (
<div>
{status === 'revalidating' && (
<span class="text-sm text-muted">Refreshing...</span>
)}
<ul>
{data.movies.results.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
</div>
);
});
Error handling inside the render function#
When the loader rejects, the status is error. That arm also carries the last good data (so prior content can stay visible), and reload from useReload() lets the user retry:
import { useReload } from 'hono-preact';
const MoviesView = moviesLoader.View((s) => {
const { reload } = useReload();
if (s.status === 'loading') return <p>Loading...</p>;
if (s.status === 'error')
return (
<div role="alert">
<p>Couldn't load movies: {s.error.message}</p>
<button onClick={reload}>Retry</button>
</div>
);
return (
<ul>
{s.data.movies.results.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
);
});
A cold error (the very first load fails, with no prior data) is caught by the loader's error boundary and the page-level errorFallback before the render function runs, so the error arm always carries a data value.
s.error.message is Loader failed in production; the loader's real thrown message and name reach the client only in dev.
The LoaderState union#
The render function receives a LoaderState<T> discriminated union. Match on status:
status | Fields | When |
|---|---|---|
'loading' | data?: never | Cold load before the first response. No data yet. |
'success' | data: Serialize<T> | The loader resolved; data is the value. |
'revalidating' | data: Serialize<T> | A background refresh is in flight; data is the previous value (stale-while-revalidate). |
'error' | error: Error, data: Serialize<T> | The loader rejected; data is the last good value. |
In the success, revalidating, and error arms data is narrowed to Serialize<T>, so there is no undefined to guard. Because the loading arm declares data?: never, data is readable on the un-narrowed union as Serialize<T> | undefined (undefined only while loading), so a render that reads data directly does not need to narrow on status first. The render function also receives any props declared by the generic P (see prop passthrough in loaders). The imperative reload() callback is read from useReload(), not passed as a render arg (see Reloading).
Page-level error fallback#
definePage accepts a page-level errorFallback that catches errors from the rest of the page tree (for example, 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 inside the .View() render function. The page-level errorFallback is the outer safety net.
See also#
- Server Loaders: where
.View()comes from. - Streaming: state-based loading for streaming loaders.
- Middleware: the
usebinding. - Project Structure: the full
definePagebindings list (includingWrapper).