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

Realtime Channels#

Realtime channels let you push live data from the server to the client without polling. A Channel is a typed address; publish(topic, message) fires from a server action after a mutation; route.loader(liveStream({ topic, load })) subscribes any connected client to that topic and pushes a fresh data snapshot on every publish. When the event itself is the data, eventStream(topic, signal) yields each published payload directly to a streaming loader.

Example: live shared counter#

A single-instance signal channel (no route params, no payload) that signals all live loaders to refetch the current count.

counter-channel.ts (shared between server and action):

import { defineChannel } from 'hono-preact';

// A signal channel: defineChannel('name')<void>(). No payload; the subscriber
// calls load() for the fresh value. Published with no message argument.
export const counterChannel = defineChannel('counter')<void>();

counter.server.ts (server module for the /counter route):

import { serverRoute, liveStream } from 'hono-preact';
import { counterChannel } from './counter-channel.js';
import { getCount } from './counter-db.js';

const route = serverRoute('/counter');

export const serverLoaders = {
  count: route.loader(
    liveStream({
      // topic(ctx) returns the Topic this loader subscribes to.
      topic: (_ctx) => counterChannel.key(),
      // load(ctx) is called on connect and on every publish to the topic.
      load: async (_ctx) => getCount(),
    })
  ),
};

counter.tsx (page component):

import type { StreamStatus } from 'hono-preact';
import { serverLoaders } from './counter.server.js';

const countLoader = serverLoaders.count;

// The accumulating .View form folds every pushed chunk into `data`.
// `initial` seeds the value before the first chunk arrives;
// `reduce` folds each chunk. For a simple replace-on-every-push pattern,
// reduce just returns the latest chunk.
// Branch on `status`: `connecting` (pre-first-chunk) and a cold `error` (the
// connect failed before any chunk) carry no data, so guard them first.
const CountDisplay = countLoader.View<number>(
  (s) => {
    if (s.status === 'error') return <p>Disconnected.</p>;
    if (s.status === 'connecting') return <p>Connecting...</p>;
    return (
      <div>
        <p>Count: {s.data}</p>
        <p>Status: {s.status}</p>
      </div>
    );
  },
  {
    initial: 0,
    reduce: (_acc, chunk) => chunk,
  }
);

export default function CounterPage() {
  return (
    <main>
      <CountDisplay />
    </main>
  );
}

counter.action.ts (server action that mutates and publishes):

import { defineAction } from 'hono-preact';
import { publish } from 'hono-preact';
import { counterChannel } from './counter-channel.js';
import { incrementCount } from './counter-db.js';

export const increment = defineAction(async () => {
  await incrementCount();
  // Signal channel: no message argument.
  publish(counterChannel.key());
});

Every client with the counter page open receives the new count within milliseconds of the action completing, without polling.

How it works#

Three pieces cooperate to wire up a live shared counter (or any data that changes on server events):

  1. Define a channel with defineChannel. The name uses the same /:param grammar as route paths; channel.key(params) builds a branded Topic<Payload> that ties publish and subscribe together at the type level.
  2. Subscribe from a serverLoaders entry by composing liveStream({ topic, load }) into route.loader(...). The loader runs load once on connect and again on every publish to topic. liveStream is inherently live, so no { live: true } flag is needed. The framework streams the results to the client over SSE so you consume them with the accumulating loader.View(render, { initial, reduce }) form.
  3. Publish from a server action by calling publish(channel.key(params), message). Every connected live loader subscribed to that topic re-runs load and pushes the new value.

Parameterized channels#

When the same data shape is segmented per resource, include the resource id in the channel name:

import { defineChannel, type Channel, type Topic } from 'hono-preact';

// Type is Channel<'board/:boardId', { taskId: string; to: string }>
export const boardChannel = defineChannel('board/:boardId')<{
  taskId: string;
  to: string;
}>();

// In a server action: boardChannel.key({ boardId }) is a Topic<{ taskId, to }>
import { publish } from 'hono-preact';
publish(boardChannel.key({ boardId: 'b1' }), { taskId: 't7', to: 'done' });

The topic and load functions in liveStream receive the same ctx, so both can read ctx.location.pathParams to key the subscription to the route:

import { serverRoute, liveStream } from 'hono-preact';

const route = serverRoute('/board/:boardId');

export const serverLoaders = {
  tasks: route.loader(
    liveStream({
      topic: (ctx) =>
        boardChannel.key({ boardId: ctx.location.pathParams.boardId }),
      load: async (ctx) => getTasks(ctx.location.pathParams.boardId),
    })
  ),
};

Cross-connection fan-out#

Live loaders are server-to-client over SSE. Publishing to a topic fans out to every live loader subscribed to that topic. On Node, the in-process bus reaches all connections on the same instance. On Cloudflare Workers, each request runs in an isolated Worker instance, so fan-out is backed by a Durable Object: a subscribe holds a Worker-to-DO socket and publish() POSTs to the topic's DO, which fans the event out to every subscriber across isolates. This uses the same HONO_PREACT_REALTIME Durable Object binding rooms use; see Cloudflare setup for the binding. Note publish() syncs the event, not your state: subscribers re-run their load() to read the current shared state.

API reference#

defineChannel(name)<Payload>()#

Defines a typed channel. The name uses the /:param grammar. The Payload type parameter sets the message type. A void payload (the default) is a signal channel that publishes with no message.

const c = defineChannel('board/:boardId')<{ taskId: string }>();
TypeDescription
namestringChannel address, e.g. 'board/:boardId'. Params use :name syntax.
Payloadtype paramMessage type. Defaults to void (signal channel, no message).

Each : in name starts a param whose own name must be one or more of [A-Za-z0-9_], optionally followed by a single ?, *, or + modifier (e.g. :id, :id?, :rest*, :rest+). defineChannel throws immediately, at definition time, if any segment carries a : outside that grammar, including a : that appears mid-segment rather than at its start (e.g. 'board:boardId').

Returns a Channel<Name, Payload> with one method:

MethodSignatureDescription
channel.key(params?)(...args) => Topic<Payload>Builds a branded Topic<Payload>. For a param-less name the argument is omitted; for a name with params the argument is { [paramName]: string }.

publish(topic, message?)#

Publishes to a typed topic from a server action or server agent. Every live loader subscribed to topic re-runs its load and pushes the result to connected clients.

publish addresses live loaders, not rooms. The two are separate namespaces even when they share a channel key, so publishing to the key a room is bound to does not reach that room's members, and a room's own traffic does not re-run live loaders on the same key. To send to a room's members, broadcast from inside the room (conn.broadcast).

import { publish } from 'hono-preact';

publish(boardChannel.key({ boardId }), { taskId, to }); // payload channel
publish(counterChannel.key()); // signal channel
ArgumentTypeDescription
topicTopic<P>The topic to publish to. Built with channel.key(params).
messagePRequired for payload channels; omitted for void (signal) channels.

liveStream({ topic, load })#

A generator helper for channel-driven live loaders. Yields load(ctx) once on connect, then re-runs and pushes the result on every publish to topic(ctx). Because liveStream produces an inherently unbounded subscription, liveness is implied: compose it directly as route.loader(liveStream({ topic, load })) or defineLoader(liveStream({ topic, load })) without any { live: true } flag.

OptionTypeDescription
topic(ctx: LoaderCtx) => Topic<unknown>Returns the topic this loader subscribes to. Called with the same context as load.
load(ctx: LoaderCtx) => Promise<T>Produces the data snapshot. Called on connect and on every publish.

The composed loader returns a LoaderRef<T, true>. Consume it with the accumulating form ref.View(render, { initial, reduce }). The StreamStatus and .View option table are described on the Live Loaders page.

eventStream(topic, signal)#

Subscribe to a topic as an async generator of its published payloads. Where liveStream re-runs a load snapshot on every publish (and discards the message), eventStream delivers each published payload itself, in order: the right shape for feeds and tickers where the event is the data.

import { defineChannel, defineLoader, eventStream } from 'hono-preact';

const activityChannel = defineChannel('activity')<ActivityEvent>();

export const serverLoaders = {
  activity: defineLoader(
    async function* ({ signal }) {
      for await (const e of eventStream(activityChannel.key(), signal)) {
        yield e;
      }
    },
    { live: true }
  ),
};
ArgumentTypeDescription
topicTopic<Payload>The channel topic to subscribe to. Build it with channel.key(...).
signalAbortSignalEnds the stream and removes the subscription when aborted. Pass the loader's signal.

Returns AsyncGenerator<Serialize<Payload>, void, unknown>. Payloads arrive in publish order and buffer while the consumer is busy. The yield type is the JSON wire shape (Serialize<Payload>): on Cloudflare a payload crosses isolates as JSON, so a Date arrives as its ISO string. If the underlying subscription drops, the generator throws, terminating the stream rather than going silently stale.

The per-subscription buffer is capped at 128 payloads. A stalled consumer (a streaming loader that stops pulling) can't grow the queue without bound: once the buffer is full, further publishes are dropped (the newest is dropped, the oldest kept) and a one-time warning is logged. A loader consuming eventStream should drain the generator continuously rather than pausing for long stretches between reads.

Type exports#

import type { Channel, Topic } from 'hono-preact';
import type { StreamStatus } from 'hono-preact';

See also#

  • Live Loaders: the persistent-layout streaming pattern, .View accumulating form, and StreamStatus reference.
  • Server Loaders: non-live loaders and the full defineLoader option table.
  • Server Actions: where publish is typically called.