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

Server Caller#

Loaders and actions can call each other server-side without an HTTP round-trip using ctx.call. The called procedure runs in the same request scope, its input is validated, and the result carries the authored type with no JSON serialization. For testing outside a live request, createCaller(c) binds the same mechanism to any Hono Context you provide.

Calling from inside a handler#

Every loader and action receives a call method on its context object. Pass another loader ref as the first argument to invoke it directly:

// src/pages/movie.server.ts
import { serverRoute } from 'hono-preact';
import { serverLoaders as reviewLoaders } from './reviews.server.js';

const reviewsLoader = reviewLoaders.default;
const route = serverRoute('/movies/:id');

export const serverLoaders = {
  default: route.loader(async (ctx) => {
    const movie = await fetchMovie(ctx.location.pathParams.id);
    const reviewsResult = await ctx.call(reviewsLoader, {
      location: { pathParams: ctx.location.pathParams },
    });
    if (!reviewsResult.ok) throw reviewsResult.outcome;
    return { movie, reviews: reviewsResult.value };
  }),
};

ctx.call returns a CallResult<T> discriminated union. Narrow on ok before reading value. A deny or redirect from the inner procedure surfaces as { ok: false, outcome } rather than throwing into the outer handler, so the caller decides whether to propagate or handle it:

const result = await ctx.call(anotherLoader, { location });
if (result.ok) {
  // result.value: T (the authored type, not Serialize<T>)
  return { combined: result.value };
} else {
  // propagate any deny or redirect from the inner loader
  throw result.outcome;
}

Actions also receive ctx.call. Use it to delegate to a shared action from within another action:

// src/pages/orders.server.ts
import { defineAction } from 'hono-preact';
import { serverActions as notifyActions } from './notifications.server.js';

export const serverActions = {
  createOrder: defineAction(async (ctx, payload: { items: string[] }) => {
    const order = await db.orders.create(payload);
    const notifyResult = await ctx.call(notifyActions.sendConfirmation, {
      orderId: order.id,
    });
    if (!notifyResult.ok) throw notifyResult.outcome;
    return { orderId: order.id };
  }),
};

Testing with createCaller(c)#

Outside a running request (in unit or integration tests), use createCaller to build a caller bound to a Hono Context:

import type { Context } from 'hono';
import { createCaller } from 'hono-preact';
import { serverLoaders } from './movie.server.js';

it('returns the movie', async () => {
  // The framework does not mint a synthetic Context; capture a real one from a
  // one-off middleware wrapped around a test request.
  let c!: Context;
  app.use('*', async (ctx, next) => {
    c = ctx;
    await next();
  });
  await app.request(new Request('https://app.test/movies/42'));
  const caller = createCaller(c);

  const result = await caller.call(serverLoaders.default, {
    location: { pathParams: { id: '42' } },
  });

  expect(result.ok).toBe(true);
  if (result.ok) {
    expect(result.value.movie.id).toBe(42);
  }
});

createCaller(c) reuses an already-open request scope if one exists, and otherwise opens a fresh scope bound to c. In a test there is normally no active scope, so it runs standalone; inside a live handler ctx.call goes through the same mechanism and reuses the request's active scope automatically.

Streaming loaders and actions#

Streaming refs are first-class through the same call surface. Calling a streaming loader runs its middleware and schema coercion up front, then resolves to the loader's async generator; iterate it to run the loader body. Pass signal to end the stream from the caller's side:

import { createCaller } from 'hono-preact';
import { serverLoaders } from './projects-shell.server.js';

it('streams activity events', async () => {
  const ctrl = new AbortController();
  const r = await createCaller(c).call(serverLoaders.activity, {
    signal: ctrl.signal,
  });
  expect(r.ok).toBe(true);
  if (r.ok) {
    const events = [];
    for await (const e of r.value) {
      events.push(e);
      if (events.length === 3) break;
    }
    ctrl.abort();
  }
});

A middleware deny or redirect still surfaces as { ok: false, outcome } before the generator is produced. An error thrown mid-stream propagates from the generator itself (a rejected next()), matching the HTTP path where the SSE stream terminates.

A streaming action resolves to AsyncGenerator<TChunk, TResult>: iterate for chunks; the final next()'s value (when done is true) is the action's return value.

How it works#

  • Runs the procedure's own use middleware. The called procedure's per-unit use array is applied: auth guards, rate limits, and any observers declared on defineLoader or defineAction. The app-level chain and the page (route) middleware are not run for the call: the app chain already fired for the outer request, and the callee's own route-level middleware is bypassed entirely. So a guard placed only on a route node does not protect a procedure reached through ctx.call from a different route. Put any guard that must protect a procedure regardless of how it is entered on that procedure's own defineLoader / defineAction({ use }), not only on its route.
  • Reuses the live request scope. Inside a live handler, ctx.call inherits the outer request's async-local storage context, so the Hono Context, session state, and middleware side-effects are shared. createCaller(c) used outside a handler opens a fresh scope per call.
  • Returns the authored type, not the wire shape. No JSON round-trip occurs. The return type of ctx.call(loader) is T (the loader's declared return), not Serialize<T>. A Date field stays a Date.
  • Does not share the loader result cache. The loader result cache is populated on the SSR page-render path. ctx.call invokes the loader function directly, so the result is always freshly computed. Two calls to the same loader produce two executions.
  • Validates inputs. If the called loader has a searchSchema or paramsSchema, or the called action has an input schema, the caller validates the supplied location or payload before invoking the function. A validation failure surfaces as { ok: false, outcome }, not a raw throw.
  • Unexpected errors reject the promise. ctx.call resolves a redirect, deny, or validation failure to { ok: false, outcome }, but an unexpected thrown error (a bug or exception inside the callee) propagates as a rejected promise. Wrap the call in try/catch if you need to contain those.
  • Does not apply the callee's timeoutMs. A loader's or action's timeoutMs is a deadline the HTTP handler enforces at the request entrypoint; ctx.call runs the procedure in-process under the outer request's signal, so the callee's own timeoutMs is not applied here. If an in-process call needs its own deadline, race the returned promise against one yourself.

Limitations#

  • No synthetic context minting. createCaller(c) accepts a Context you supply. Obtain one from app.request(new Request(...)) or your test helper. The framework does not mint a synthetic context.

API reference#

ctx.call(loader, opts?)#

ArgumentTypeDescription
loaderLoaderRef<T, false> | LoaderRef<T, true>The loader to call. A single-value loader resolves to its value; a streaming loader resolves to its async generator.
opts.locationCallLoaderLocation (optional)Path, path params, and search params for the loader's location. Defaults to the current request path with empty params.
opts.signalAbortSignal (optional, streaming loaders)Composed with the request's signal and threaded to the loader as ctx.signal; aborting it ends the stream.

Returns Promise<CallResult<T>> for a single-value loader, Promise<CallResult<AsyncGenerator<T, void, unknown>>> for a streaming loader.

ctx.call(action, payload)#

ArgumentTypeDescription
actionActionRef<TPayload, TResult, TChunk>The action to call. A single-value action resolves to its result; a streaming action resolves to its async generator.
payloadTPayloadThe action payload. Validated against the action's input schema when one is present.

Returns Promise<CallResult<TResult>> for a single-value action, Promise<CallResult<AsyncGenerator<TChunk, TResult, unknown>>> for a streaming action (an async generator handler with a chunk type).

createCaller(c)#

import { createCaller } from 'hono-preact';

const caller = createCaller(c: Context): ServerCaller;

Returns a ServerCaller with a call method that accepts the same arguments as ctx.call. Each call reuses the active request scope if one is already open, otherwise it opens a fresh scope bound to c. Use in tests and any context outside a live request handler.

CallResult<T>#

type CallResult<T> = { ok: true; value: T } | { ok: false; outcome: Outcome };

Narrow on ok to access value or inspect outcome. The outcome type is Outcome (one of redirect | deny | timeout | render). Re-throw it to propagate to the outer handler, or inspect outcome.__outcome to handle specific cases.

CallLoaderLocation#

type CallLoaderLocation = {
  path?: string;
  pathParams?: Record<string, string>;
  searchParams?: Record<string, string>;
};

Optional location shape for ctx.call(loader, { location }). All fields are optional; omitted fields default to the current request path and empty params.

See also#