hono-preact

Streaming Loaders & Actions

Static loaders return a single value once. Streaming loaders and actions are async generators that yield values over time. The consumer API (loader.useData(), useAction) is the same; only the author shape changes.

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

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

Use .View() to create a component that re-renders on each new chunk. The render function receives the latest data value plus error and reload:

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

const dataLoader = serverLoaders.default;

const DashboardView = dataLoader.View(
  ({ data, error, reload }) => (
    <>
      {error && <p>Live updates paused: {error.message}</p>}
      <p>
        Count: {data.count}, load: {data.load}
      </p>
    </>
  ),
  { fallback: <p>Loading...</p> }
);

export default definePage(DashboardView);

loader.useData() always returns the latest yielded value. loader.useError() returns the current error or null. 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 unblock another.

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

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

  cast: defineLoader(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;

const Summary = summary.View(({ data }) => <SummaryCard data={data} />, {
  fallback: <SummarySkeleton />,
});

const Cast = cast.View(({ data }) => <CastList items={data} />, {
  fallback: <CastSkeleton />,
});

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

export default definePage(MovieDetail);

<Summary /> and <Cast /> each own their Suspense boundary. <Summary /> can paint immediately while <Cast /> is still streaming. Each .View() component gets a separate streaming-SSR registry key so the server can flush chunks for both concurrently.

What happens on first paint

  1. The server runs each serverLoader function and renders the page with first chunks for all loaders. The response stays open.
  2. As subsequent chunks arrive, the server flushes <script>__HP_STREAM__.push(...)</script> tags inline into the ongoing HTML response, keyed per loader.
  3. The browser receives and executes those tags as they arrive. On hydration, the client picks up from the latest pushed value for each loader and continues listening.

The first-load experience is full SSR with progressive enhancement: the user sees real data immediately, and it keeps updating without a separate fetch or WebSocket.

Errors

Before the first chunk: the error propagates through the normal Suspense and error boundary path. If you provide errorFallback in .View(), it renders instead of the content. Otherwise the error boundary above catches it.

After the first chunk: the stream is already open and the page is rendered. error in the .View() render function surfaces the error; data keeps returning the last good value. The page stays mounted with stale data visible.

const StatsView = dataLoader.View(({ data, error }) => (
  <>
    {error && <p>Live updates paused: {error.message}</p>}
    <p>Visitors: {data.visitors}</p>
  </>
));

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/issues/i-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 '{"__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