hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn

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#

Navigationstatus on entry
SSR (first load)success. Data is preloaded into the HTML.
Hydrationsuccess. 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:

statusFieldsWhen
'loading'data?: neverCold 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#