hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Signals
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

Live Loaders & Persistent UI#

A live loader is a streaming loader that connects once and survives intra-scope navigation. It is the mechanism for building UI that persists visually across route changes: a notification bar, a sidebar feed, a presence indicator, or any widget that should keep running while the user navigates within a section of the app.

Example: activity bar#

The demo at /demo/projects uses a live activity loader on the projects-shell layout to show a real-time event feed across all project pages.

Server module (projects-shell.server.ts):

import { serverRoute, eventStream, type LoaderCtx } from 'hono-preact';
import {
  activityChannel,
  recentActivityEvents,
  type ActivityEvent,
} from './activity-stream.js';

// Backfill recent history, then stream every event published on the activity
// channel (server actions publish real events with
// `publish(activityChannel.key(), e)`).
async function* activityStream({
  signal,
}: LoaderCtx): AsyncGenerator<ActivityEvent, void, unknown> {
  for (const e of recentActivityEvents(5)) yield e;
  for await (const e of eventStream(activityChannel.key(), signal)) {
    yield e;
  }
}

// The projects route node declares `use: requireSession`; the subtree
// binding resolves that gate for the shell's loader RPCs from this
// declared pattern.
const route = serverRoute('/demo/projects/*');

export const serverLoaders = {
  default: route.loader(shellLoader),
  activity: route.loader(activityStream, { live: true }),
};

Layout component (projects-shell.tsx):

import type { StreamStatus } from 'hono-preact';
import { serverLoaders } from './projects-shell.server.js';
import type { ActivityEvent } from './activity-stream.js';

const activityLoader = serverLoaders.activity;
const MAX = 50;

function Feed({
  events,
  status,
}: {
  events: ActivityEvent[];
  status: StreamStatus;
}) {
  const connected = status === 'open';
  return (
    <div role="log" aria-label="Recent activity">
      <span aria-hidden class={connected ? 'dot dot--live' : 'dot dot--idle'} />
      {events.length === 0 ? (
        <p>Listening for activity...</p>
      ) : (
        <ul>
          {events.map((e) => (
            <li key={e.id}>
              {e.actor}: {e.taskTitle}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// The accumulating `.View` form (selected by `initial` + `reduce`) tracks its
// connection status as state, so the bar hydrates cleanly inside the layout.
// Branch on `status` for each affordance: `connecting` (pre-first-chunk) and a
// cold `error` (the connect failed before any chunk) carry no data, so guard
// them before reading `s.data`.
const ActivityBar = activityLoader.View<ActivityEvent[]>(
  (s) => {
    if (s.status === 'open' || s.status === 'closed')
      return <Feed events={s.data} status={s.status} />;
    if (s.status === 'error') return <p>Activity stream disconnected.</p>;
    return <p>Connecting to activity...</p>;
  },
  {
    initial: [],
    reduce: (acc, e) => [e, ...acc].slice(0, MAX),
  }
);

export default function ProjectsShell({
  children,
}: {
  children: ComponentChildren;
}) {
  return (
    <div class="projects-shell">
      <main>{children}</main>
      <ActivityBar />
    </div>
  );
}

Navigating between /demo/projects/alpha and /demo/projects/beta does not remount ProjectsShell, so ActivityBar keeps its accumulated events and the stream stays open.

How it works#

Persistent UI is expressed as a child of a layout. A layout stays mounted across navigations between its child routes, so anything rendered inside the layout survives those navigations too. Attach a live loader to the layout's server module to connect a long-lived stream to that layout, and consume the stream with loader.View(render, { initial, reduce }) inside a layout component. The stream connects once when the layout mounts and reconnects only when the user leaves the layout's scope entirely.

Two pieces of the same loader API drive this pattern:

  • defineLoader(fn, { live: true }) marks a loader as live. A live loader is never invoked during SSR (an infinite generator cannot hang the document response), and its timeout defaults to false (no 30-second cap) unless timeoutMs is set explicitly. A live loader is consumed with the accumulating loader.View(render, { initial, reduce }) form, or with loader.useData(initial, reduce) inside loader.Boundary (collect-mode; see Server Loaders) for descendants that fold the same stream independently. The single-value .View(render) form (no reduce) is not available on a live loader (it has no single value): the form is fixed by the loader's type (defineLoader({ live: true }) returns a LoaderRef<T, true>), so using the wrong form is a compile error.
  • loader.View(render, { initial, reduce }) is the accumulating form of the standard consumption convention. It folds every chunk into accumulated state, and the render function receives a StreamState<Acc> discriminated union to match on status. status === 'connecting' on SSR and before the first chunk arrives (this arm carries no data); after the first chunk status is 'open', with the accumulator on data. A .Boundary consumer additionally sees 'reconnecting' while a reload() resubscribes over chunks it already has. Switch on status inside the render function to show a connecting affordance.

On Cloudflare#

publish(), route.loader(liveStream({ topic, load })), and eventStream(topic, signal) share the same cross-isolate fan-out on Cloudflare Workers. Cross-isolate fan-out (a publish() in one request waking a live loader streaming in another isolate) is backed by the same HONO_PREACT_REALTIME Durable Object that powers rooms, so no extra setup is needed once that binding is in your wrangler.jsonc (see the rooms docs, "Cloudflare setup").

One thing to keep in mind: publish() syncs the event, not your state. It fans a "something changed, re-run" wake out to every connected live loader; each loader then re-runs its load(ctx) and reads the current value. So your load must read from a shared source of truth that every isolate can see: a database, KV, D1, or Durable Object storage. A module-level let count = 0 works on Node (one process shares it) but is per-isolate on Workers, so two tabs would drift apart even though the wake reached both. Read shared state in load, and publish() after you write it. eventStream is the exception to the "event, not state" rule: it delivers the published payload itself (as its JSON wire shape), so a feed of self-contained events needs no shared read on the subscriber side.

Scoping the persistence#

Choose the layout based on the scope you want:

ScopeRoute table entryWhat persists
App-wideRoot route with layout and path: '*' childrenAcross the whole app
SectionPrefix route group (e.g. path: '/projects')Within /projects/**
Single sub-treeAny nested layout groupWithin that sub-tree

The stream is tied to the layout's lifecycle. It connects when the layout mounts and is aborted (via ctx.signal) when the layout unmounts, which happens when the user navigates outside the layout's scope.

Known behavior#

Scope-exit blip#

When the user navigates out of the layout's scope, the layout unmounts and the stream closes. If the user navigates back in, the layout remounts and the stream reconnects from scratch, starting with initial again. This is the expected lifecycle: there is no cross-mount cache for live loaders.

No automatic reconnect on drop#

A live loader opens the stream once and does not auto-reconnect. If the connection drops mid-session (a network blip, a server restart, an idle proxy timeout), status becomes 'error' or 'closed' and stays there until the layout unmounts and remounts (scope exit and re-entry). To recover in place, branch on status and call reload() from useReload() (for example, a "Reconnect" button shown when status === 'error'); reload() resets data to initial and re-opens the stream.

A reload() that fails to reconnect does not cost you what is already on screen. Under .Boundary + useData(initial, reduce), the retained chunks are kept until the new connection delivers its first chunk. While the resubscribe is in flight status is 'reconnecting' and the arm still carries data, so you can show a "reconnecting" affordance over the last good fold; if the reconnect fails you get status === 'error' with that fold intact, and if it succeeds the new stream swaps in. Retrying is therefore safe to offer as a button: the worst case is that nothing changes.

Failure or close before the first chunk#

A stream lifecycle event is data, not an exception: failures and clean ends always travel through the render function's status field, at every point in the stream's life. A failure on the initial connect (the stream fails or times out before its first chunk arrives) surfaces as status === 'error', exactly like a mid-stream drop; because no chunk has arrived, that cold-connect error carries no accumulated value, so its data is undefined. Branch on it (show an error UI rather than handing your render an empty accumulator), the way the 'error' row in the table below describes. A stream that ends cleanly before its first chunk simply stays on the connecting affordance: there is no accumulator to show.

Because the cold-connect error surfaces in-view, the reconnect-on-error pattern above works on the very first connect too: a "Reconnect" button shown when status === 'error' calls reload() to re-open the stream. A failed reload() behaves identically, so the in-place recovery pattern is uniform across the first connect and every later reconnect.

errorFallback does not catch a stream connect failure. It only catches a render-time throw from the render subtree (and, during SSR, a throw raised while the document renders). Stream lifecycle errors never reach it, in either consumption form: under .Boundary + useData(initial, reduce) a failed connect surfaces the same way, as status === 'error' with no data, and the children stay mounted to render it.

API reference#

defineLoader(fn, { live })#

OptionTypeDefaultDescription
livebooleanfalseMarks this loader as a long-lived client subscription. Skipped on SSR; timeout defaults to false. Consume via the accumulating loader.View form.

All other defineLoader options (params, cache, timeoutMs, use) work the same for live loaders. See Server Loaders for the full option table.

loader.View(render, { initial, reduce }) (accumulating form)#

Passing initial and reduce selects the accumulating form of .View. The render function receives a StreamState<Acc> discriminated union; switch on status (connecting | open | closed | error). The connecting arm carries no data; the other arms carry the folded accumulator as data.

loader.View<Acc>(
  render: (args: StreamState<Acc>) => ComponentChildren,
  opts: {
    initial: Acc;
    reduce: (acc: Acc, chunk: Serialize<T>) => Acc;
    errorFallback?:
      | ComponentChildren
      | ((err: Error, reset: () => void) => ComponentChildren);
  }
): FunctionComponent

chunk is Serialize<T>: the JSON round-trip of the server-side chunk, the same wire shape useData() and the single-value .View surface (a Date field arrives as a string).

OptionTypeDescription
initialAccSeed value; also the value passed to the render function on SSR and before the first chunk arrives.
reduce(acc: Acc, chunk: Serialize<T>) => AccFolds each incoming chunk (the JSON wire shape) into the accumulated value. Called for every chunk in order.
errorFallbackComponentChildren or (err, reset) => ComponentChildrenRendered if the render subtree throws (or, during SSR, a throw while the document renders). It does not catch stream connect failures: a cold-connect error surfaces in-view as status === 'error' instead. The function form receives the error and a reset to retry.

The render function receives a StreamState<Acc> discriminated union. Match on status:

statusFieldsWhen
'connecting'data?: neverSSR and before the first chunk arrives. No data yet.
'open'data: AccThe stream is live; data is the folded accumulator, updated after each chunk.
'closed'data: AccThe stream ended cleanly after at least one chunk; data is the last accumulator.
'error'error: Error, data?: AccThe stream errored (on the initial connect, or after a chunk). data is the last accumulator if a chunk had arrived, otherwise absent: guard it.

The connecting arm declares data?: never, so data is readable on the un-narrowed union as Acc | undefined (undefined while connecting, or on a pre-chunk error) without narrowing on status first. The imperative reload() (resubscribe: abort the current stream, reset data to initial, reconnect, and fold afresh) is read from useReload(), not passed as a render arg.

StreamStatus values:

ValueMeaning
'connecting'SSR or pre-hydration; the first chunk has not arrived yet.
'open'At least one chunk has arrived; the stream is active.
'reconnecting'A reload() resubscribe is in flight and the previous fold is STILL PRESENT. data is the last good value, not initial.
'closed'The generator returned normally; no more chunks are expected.
'error'The stream errored. error holds the cause.

reconnecting is the arm that keeps a reload from blanking the screen: it carries data, so a view that was showing a fold keeps showing it while the resubscribe is in flight. That means a two-way test is wrong:

-{s.value.status === 'open' ? <Feed data={s.value.data} /> : <Spinner />}
+{s.value.status === 'connecting' ? <Spinner /> : <Feed data={s.value.data} />}

The first replaces a live fold with a spinner on every reload(), which is exactly what reconnecting exists to prevent. Narrow on the arm that has no data, not on the one that does.

loader.useData(initial, reduce) and loader.Boundary (collect-mode)#

loader.Boundary on a live loader hosts the stream in collect-mode: it renders its children eagerly and puts the raw chunk stream on context. Each descendant calls loader.useData(initial, reduce) with its own reduce, folding the same underlying subscription independently:

function EventCount() {
  const count = activityLoader.useData(0, (n) => n + 1);
  return (
    <span>{count.value.status === 'connecting' ? '-' : count.value.data}</span>
  );
}

function LatestActor() {
  const latest = activityLoader.useData('', (_prev, e) => e.actor);
  return <span>{latest.value.data ?? 'waiting...'}</span>;
}

function ActivityHeader() {
  return (
    <activityLoader.Boundary>
      <EventCount />
      <LatestActor />
    </activityLoader.Boundary>
  );
}

useData(initial, reduce) returns a ReadonlySignal<StreamState<Acc>>, the same discriminated union .View's render function receives; read .value and switch on .value.status. A component that mounts after chunks have already arrived still folds the full retained log, so a late mount never misses earlier chunks. This is the same .Boundary + useData pattern the single-value form uses; see Server Loaders.

Keep the reducer pure#

Write reduce as a function of (acc, chunk) and nothing else. The fold is created on the first render and reused for the life of the subscription, so a reducer that closes over a prop or piece of state keeps using the value it captured then:

// WRONG: `rate` is captured once. Ticks arriving after the user switches
// currency are still folded at the old rate, and the total is quietly wrong.
const total = ticker.useData(0, (acc, tick) => acc + tick.qty * rate);

Fold the raw numbers instead, and apply the changing part where you render:

const qty = ticker.useData(0, (acc, tick) => acc + tick.qty); // pure
return <p>{qty.value.data * rate}</p>; // rate applied here

Nothing warns you about the first version, which is why it is worth knowing. We keep the fold pinned on purpose: honouring a new reducer means re-folding every chunk received so far, and since an inline arrow is a new function on every render that re-fold would happen on every render too. On a long stream that is quadratic work for a value that was already correct.

Which form to reach for#

The two consumption forms differ in what they keep, and on a long stream that difference is the whole decision.

KeepsReach for it when
.View(render, { initial, reduce })One accumulator. Constant memory, however long the stream runs.One consumer folds the stream.
.Boundary + useData(initial, reduce)Every chunk, for the life of the subscription.Several components fold the same stream their own way.

.Boundary retains the chunks because that is the only way a component mounting at minute ten can arrive at the same answer as one that mounted at minute zero. Each consumer folds forward from its own cursor, so folding is cheap no matter how many consumers there are, but the log itself grows with the stream and is released only when the subscription resets (a reload, or the component unmounting).

For a bounded stream (a progressive result set, a job's progress events) that is exactly what you want. For an endless one (a live feed that runs as long as the tab is open) with a single consumer, prefer .View, which never accumulates a log at all.

We do not truncate the log for you, and that is deliberate: a fold is arbitrary user code, so dropping chunks from underneath it would quietly produce a wrong answer rather than an error. A running total would simply be too small, with nothing to indicate it.

Type exports#

import type { StreamStatus } from 'hono-preact';

See also#