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

Streaming Loaders & Actions#

Reach for streaming when the data changes while the user is looking at it: dashboards, log tails, chat token streams, progressive search results. If your data is stable for the lifetime of a request, a plain async function is simpler and should be your first choice.

Streaming loaders and actions are async generators that yield values over time. A streaming loader is consumed with the accumulating loader.View(render, { initial, reduce }) form, which folds each chunk into a value and hands the render function a StreamState to switch on; a streaming action is consumed with useAction's onChunk callback.

Streaming loaders#

Author shape#

A streaming loader is an async function* that yields T and receives a LoaderCtx:

// src/pages/dashboard.server.ts
import { defineLoader, type LoaderCtx } from 'hono-preact';

export type Snapshot = { count: number; load: number };

export const serverLoaders = {
  default: defineLoader(async function* (
    ctx: LoaderCtx
  ): AsyncGenerator<Snapshot> {
    while (!ctx.signal.aborted) {
      yield await currentSnapshot();
      await new Promise((r) => setTimeout(r, 1000));
    }
  }),
};

ctx.signal is an AbortSignal that fires when the client disconnects or the component unmounts. Thread it into upstream fetch calls and subscriptions so they clean up promptly. Returning from the generator (rather than looping) also ends the stream cleanly.

For byte-level piping (e.g. forwarding a server-sent event feed without parsing), you can return a ReadableStream<Uint8Array> instead of an AsyncGenerator. The framework pipes it straight to the response. This is an escape hatch; for typed structured data, use an async generator.

Consumer shape#

A streaming loader (its fn is an async function*) has the type LoaderRef<T, true>, so it is consumed with the accumulating loader.View(render, { initial, reduce }) form only. reduce folds each yielded chunk into an accumulated value seeded by initial; the render function receives a StreamState<Acc> discriminated union you switch on (connecting | open | closed | error). The single-value .View(render) form and loader.useData() are not available on a streaming loader (it has no single value), so the wrong form is a compile error. reload is read via useReload().

For a dashboard that shows the latest snapshot, the accumulator is just that snapshot (reduce replaces it each chunk):

// src/pages/dashboard.tsx
import { definePage } from 'hono-preact';
import { serverLoaders, type Snapshot } from './dashboard.server.js';

const dataLoader = serverLoaders.default;

const DashboardView = dataLoader.View<Snapshot | null>(
  (s) => {
    if (s.status === 'connecting') return <p>Connecting...</p>;
    return (
      <>
        {s.status === 'error' && <p>Live updates paused: {s.error.message}</p>}
        {s.data && (
          <p>
            Count: {s.data.count}, load: {s.data.load}
          </p>
        )}
      </>
    );
  },
  { initial: null, reduce: (_acc, snap) => snap }
);

export default definePage(DashboardView);

To keep a growing log or progressive result list instead, accumulate into an array: { initial: [], reduce: (acc, chunk) => [...acc, chunk] }. Components re-render on each new chunk automatically.

See /demo/projects/:projectId/issues/:issueId for a running example of multi-loader streaming: the issue detail page declares multiple loaders in serverLoaders, each with its own .View() component streaming independently.

Multi-loader streaming#

When a page declares multiple loaders in serverLoaders, each .View() component streams independently. They share no boundary; one loader finishing does not affect another's loading state.

// src/pages/movie.server.ts
import { serverRoute } from 'hono-preact';

const route = serverRoute('/movie/:id');

export const serverLoaders = {
  summary: route.loader(async ({ location }) =>
    getMovie(location.pathParams.id)
  ),

  cast: route.loader(async function* ({ location }) {
    for await (const member of streamCast(location.pathParams.id)) yield member;
  }),
};
// src/pages/movie.tsx
import { definePage } from 'hono-preact';
import { serverLoaders } from './movie.server.js';

const { summary, cast } = serverLoaders;

// `summary` is a plain async loader (single value): the single-value `.View`.
const Summary = summary.View(({ data }) =>
  data ? <SummaryCard data={data} /> : <SummarySkeleton />
);

// `cast` is a streaming generator: the accumulating `.View`, folding members
// into a list and switching on the StreamState `status`.
const Cast = cast.View<CastMember[]>(
  (s) =>
    s.status === 'connecting' ? (
      <CastSkeleton />
    ) : (
      <CastList items={s.data ?? []} />
    ),
  { initial: [], reduce: (acc, member) => [...acc, member] }
);

function MovieDetail() {
  return (
    <article>
      <Summary />
      <Cast />
    </article>
  );
}

export default definePage(MovieDetail);

<Summary /> and <Cast /> each track their own state. <Summary /> can paint as soon as its value resolves while <Cast /> is still streaming members into its list.

What happens on first paint#

A streaming loader does not bake chunk data into the SSR HTML; it reconnects on the client.

  1. The server renders the page with each streaming loader in its connecting state: the render function runs with the initial accumulator and status === 'connecting', and the loader's element carries no baked value (data-loader="null").
  2. On hydration the client adopts that server-rendered node and opens the loader's stream (an SSE request). It then folds each arriving chunk through reduce, moving status from connecting to open and re-rendering as the accumulator fills.
  3. When the stream ends cleanly status becomes closed; if it drops, status becomes error (carrying the last accumulator, if any).

The first paint shows the connecting affordance (the initial value), then the live data streams in. A plain async loader, by contrast, is fully server-rendered with its value baked in. For long-lived layout-scoped subscriptions, see Live Loaders.

Errors#

A stream lifecycle event is data, not an exception: a stream failure travels through the render function's status field, never the loader's error boundary or errorFallback.

Before the first chunk: a stream that fails on the initial connect surfaces as status === 'error' with no accumulated value (data absent), exactly like a mid-stream drop. Guard data rather than handing your render an empty accumulator.

After a chunk: the error arm surfaces the error while still carrying the last good data, so the view stays mounted with the last accumulator visible.

const StatsView = dataLoader.View<Snapshot | null>(
  (s) => {
    if (s.status === 'connecting') return <StatsSkeleton />;
    return (
      <>
        {s.status === 'error' && <p>Live updates paused: {s.error.message}</p>}
        {s.data && <p>Count: {s.data.count}</p>}
      </>
    );
  },
  { initial: null, reduce: (_acc, snap) => snap }
);

A mid-stream throw reaches the client as a terminal error event on the wire. In production its message is masked as Stream failed, mirroring the non-streaming Loader failed and Action failed masking; in dev the real message and error name pass through. An expected, user-facing terminal state should be yielded as data the consumer understands rather than thrown.

Abort and cleanup#

The framework aborts ctx.signal when the client disconnects (server side) or when the component that owns the loader unmounts (client side). Pass the signal to any upstream resource that supports cancellation:

const serverLoaders = {
  feed: defineLoader(async function* (ctx) {
    const res = await fetch('https://api.example.com/feed', {
      signal: ctx.signal,
    });
    for await (const chunk of parseStream(res.body!)) {
      yield chunk;
    }
  }),
};

Polling loops should check ctx.signal.aborted at the top of each iteration (as in the example above) rather than listening for the abort event, so cleanup happens at a natural yield point.

Streaming actions#

An action can be a streaming generator that yields progress chunks and returns a final result. The type parameters on defineAction follow the generator's shapes automatically.

Author shape#

// src/pages/watched.server.ts
import { defineAction } from 'hono-preact';

export const serverActions = {
  bulkImport: defineAction(async function* (ctx, payload: { count: number }) {
    for (let i = 0; i < payload.count; i++) {
      if (ctx.signal.aborted) return { imported: i };
      await processItem(i);
      yield { count: i + 1, total: payload.count };
      await new Promise((r) => setTimeout(r, 150));
    }
    return { imported: payload.count };
  }),
};

Yielded values are the chunk type (TChunk). The return value is the final result (TResult). TypeScript infers both from the generator body.

Consumer shape#

const [progress, setProgress] = useState<{
  count: number;
  total: number;
} | null>(null);

const { mutate, data, pending } = useAction(serverActions.bulkImport, {
  onChunk: (p) => setProgress(p),
  onSuccess: (r) => console.log(`imported ${r.imported}`),
});

onChunk receives each typed chunk. onSuccess receives the typed final result (the generator's return value). data holds the final result after the stream closes.

For error handling, pass onError:

const { mutate } = useAction(serverActions.bulkImport, {
  onChunk: (p) => setProgress(p),
  onSuccess: (r) => setProgress(null),
  onError: (err) => console.error('import failed', err),
});

Chunks and the final result are delivered in order. If the generator throws, the stream closes and onError is called; onSuccess does not fire.

Form limitations for streaming actions#

Streaming actions cannot be used with <Form action={stub}>. The type signature of FormProps['action'] constrains the stub to non-streaming actions (TChunk = never), so passing a streaming action stub is a TypeScript error at compile time.

If a streaming action receives a raw POST without Accept: text/event-stream (for example, a no-JS form submission), the server responds with HTTP 405. Streaming actions are only invocable via useAction(stub) with the onChunk callback.

// This is a type error: streaming actions are not accepted by <Form>
<Form action={serverActions.bulkImport} />; // TS error

// Correct: call streaming actions programmatically
const { mutate } = useAction(serverActions.bulkImport, {
  onChunk: (p) => setProgress(p.count),
});

Debugging#

Streaming loaders and actions use a server-sent event (SSE) wire format. You can inspect the framing directly with curl:

# Streaming loader endpoint
curl -N 'http://localhost:5173/demo/projects/inf/tasks/t-1'

# Streaming action: must include Accept: text/event-stream (replace module key and action name)
curl -N -X POST http://localhost:5173/movies \
  -H 'Content-Type: application/json' \
  -H 'Accept: text/event-stream' \
  -d '{"module":"pages/movies.server","action":"bulkImport","payload":{"count":5}}'

Each chunk arrives as a data: line followed by a blank line (standard SSE framing). The final result for actions arrives as a result: line. Parsing is handled automatically by the framework on the client.

See also#