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

Signals#

Every data hook in the framework hands you a signal: a small box holding a value that tells anyone reading it when the value changes. Read .value and you get the data. Hold the box and pass it to a child, and that child updates on its own when the data changes, without re-rendering the component that fetched it.

That second part is the whole point. A loader reload, a form submission, or one person moving their cursor should update the piece of the page that shows it, not the subtree that happens to own the data.

You do not install anything to use this. signal, computed, useSignal and the rest are re-exported from hono-preact, and the data hooks return signals whether or not you ever write one yourself.

Reading data#

The shortest migration from a plain value is one property access:

import { serverLoaders } from './movies.server.js';

const moviesLoader = serverLoaders.default;

const Movies = () => {
  const { data, status } = moviesLoader.useData().value;
  if (status === 'loading') return <Spinner />;
  return (
    <ul>
      {data.movies.map((m) => (
        <li key={m.id}>{m.title}</li>
      ))}
    </ul>
  );
};

This works, and for a small page it is the right amount of code. .value reads the current data and subscribes this component, so it re-renders when the loader reloads. That is exactly what happened before signals existed.

Holding the binding#

The interesting version passes the signal down instead of reading it:

import type { ReadonlySignal } from 'hono-preact';
import type { LoaderState } from 'hono-preact';
import { serverLoaders } from './movies.server.js';

const moviesLoader = serverLoaders.default;

type MoviesState = ReadonlySignal<LoaderState<{ movies: Movie[] }>>;

// Reading `.value` in here subscribes THIS component and nothing else.
const MovieCount = ({ state }: { state: MoviesState }) => {
  const s = state.value;
  return <p>{s.status === 'success' ? s.data.movies.length : 0} movies</p>;
};

const Movies = () => {
  const state = moviesLoader.useData();
  return (
    <section>
      <ExpensiveHeader />
      <MovieCount state={state} />
    </section>
  );
};

Movies never reads .value, so it never subscribes, so a reload does not re-render it or <ExpensiveHeader>. Only <MovieCount> updates. No memo, no comparator, no dependency list.

The rule that falls out of this: read .value as late as you can, at the leaf that displays the data. Reading it early is not wrong, it just gives the update a bigger blast radius.

Forms and actions#

The same shape shows up across mutations. Each of these returns a signal:

import { useActionResult, useFormStatus } from 'hono-preact';
import { serverActions } from './movies.server.js';

const SaveIndicator = () => {
  const { pending } = useFormStatus(serverActions.addMovie).value;
  return pending ? <p>Saving...</p> : null;
};

const Feedback = () => {
  const result = useActionResult(serverActions.addMovie).value;
  if (result?.kind !== 'deny') return null;
  return <p role="alert">{result.message}</p>;
};

Both components read .value at the leaf, so a submission updates the indicator and the message without touching the form around them. See Server Actions for the full surface and Optimistic UI for useOptimistic, which returns its projection the same way.

For presence, Realtime Rooms has memberIds and member(id), which are the sharpest version of this idea: one member's row, bound to one member's signal.

Signals you write yourself#

The primitives are there when you want local reactive state that outlives a single component's render:

import { useSignal, useComputed, useSignalEffect } from 'hono-preact';

const Search = () => {
  const query = useSignal('');
  const trimmed = useComputed(() => query.value.trim());

  useSignalEffect(() => {
    document.title = trimmed.value ? `Search: ${trimmed.value}` : 'Search';
  });

  return (
    <input
      value={query.value}
      onInput={(e) => (query.value = e.currentTarget.value)}
    />
  );
};

useSignal creates one that lives for the component's lifetime. useComputed derives a new one from others and recomputes only when they change. useSignalEffect runs a side effect whenever anything it reads changes, and cleans up after itself, with no dependency array to keep in sync.

Outside components, signal, computed and effect do the same jobs at module scope, which is handy for state shared between unrelated parts of a page.

batch groups several writes so subscribers see one update instead of several:

import { batch } from 'hono-preact';

batch(() => {
  firstName.value = 'Ada';
  lastName.value = 'Lovelace';
}); // one notification, not two

untracked reads a signal without subscribing to it, for when a computed needs to look at something it should not depend on:

import { computed, untracked } from 'hono-preact';

// Recomputes when `cart` changes. Changing the currency alone does not
// re-run this, because that read is untracked.
const total = computed(() => {
  const rate = untracked(() => currency.value.rate);
  return cart.value.reduce((sum, item) => sum + item.price * rate, 0);
});

How it works#

A signal has one job: track who read it, and notify them when it changes. Components subscribe automatically by reading .value during render. A computed subscribes to whatever it read while computing, and only notifies its own readers when its result actually changes.

That last part matters more than it sounds. A computed compares its new result to the old one by ===, so returning a fresh object literal every time defeats it and pushes an update to every reader on every recompute. The framework's own hooks are careful about this; if you write a computed that projects an object, return a stable reference for unchanged values.

Three things surprise people, all of them the same surprise from different angles: the signal object's identity never changes, only its value does.

Dependency arrays freeze. A hook's signal is created once and kept for the component's lifetime, so useEffect(fn, [status]) captures a reference that is never again unequal and the effect never re-runs. Write [status.value], or use useSignalEffect and skip the array:

-useEffect(() => { announce(status); }, [status]);
+useEffect(() => { announce(status.value); }, [status.value]);

Truthiness checks pass. A signal is always an object, so if (result) is always true even when result.value is null. Test the value, not the box: if (result.value).

String and JSON conversion still work. `${count}`, String(count) and JSON.stringify(count) all produce what you expect, because a signal converts to its value. Convenient, but it means a value you forgot to unwrap can look completely fine right up until you put it in a dependency array or compare it to something.

TypeScript catches the ordinary mistakes here (reading a field that lives on the value, passing a signal where a value is expected). It cannot catch the three above, because each is legal code with a different meaning. If a component stops updating, check whether something is comparing boxes instead of values.

One copy, please. @preact/signals keeps its bookkeeping in module state, so two copies in one app fail quietly: a computed in one copy never hears about a signal from the other. Import the primitives from hono-preact rather than adding @preact/signals to your own dependencies, and you get the copy the framework already dedupes.

Props are compared shallowly#

Reading a signal changes how a component decides to re-render, and it is worth knowing about because it is on by default rather than something you opt into.

A component that touches a signal gets memoized on its props: when its parent re-renders, it only re-renders if a prop changed by ===, or if one of its own signals did. That is usually a free win. Calling loader.useData() is enough to turn it on, whether or not you read the result.

Everything you would expect to update still updates: a new prop value, a changed context, different children, local useState, a signal write. The one thing a shallow comparison cannot see is an object you changed in place:

const model = { title: 'draft' };

function Title({ m }) {
  const t = tick.value; // touches a signal, so props are compared
  return <h1>{m.title}</h1>;
}

// Elsewhere:
model.title = 'published'; // same object, new contents
forceParentRerender(); // <Title> does NOT update: `m` is === what it was

Give it a new object instead, and it updates as you would expect:

setModel({ ...model, title: 'published' });

If you have written Preact or React with memo(), this is the same rule you already follow, applied automatically. Data that arrives from a loader, an action, or a room is new on every update, so it is unaffected; this only comes up for state you own and mutate yourself.

What it costs#

@preact/signals is about 3.3 KB gzipped, and any app using loaders, actions or realtime ships it. It is a one-time cost, not a per-feature one: adding actions to an app that already has loaders does not pay for it again. An app that uses none of the data layer does not ship it at all.

API reference#

All of these are exported from hono-preact.

Hooks#

ExportSignatureDescription
useSignal(initial: T) => Signal<T>A signal that lives for the component's lifetime. Created once; later arguments are ignored.
useComputed(fn: () => T) => ReadonlySignal<T>A derived signal. Recomputes when anything fn read changes, and notifies only when the result changes by ===.
useSignalEffect(fn: () => void | (() => void)) => voidRuns fn whenever a signal it read changes. Return a function to clean up. No dependency array.

Primitives#

ExportSignatureDescription
signal(initial: T) => Signal<T>Creates a signal outside a component.
computed(fn: () => T) => ReadonlySignal<T>Derives a signal outside a component.
effect(fn: () => void | (() => void)) => () => voidRuns fn on change outside a component. Returns a dispose function.
batch(fn: () => T) => TGroups writes so subscribers are notified once at the end.
untracked(fn: () => T) => TReads signals inside fn without subscribing to them.

Types#

ExportMembersDescription
Signal<T>value: T, peek(): T, subscribe(fn): () => voidA readable and writable signal. Assign to value to update it; peek() reads without subscribing.
ReadonlySignal<T>value: T (readonly), peek(): T, subscribe(fn): () => voidWhat the data hooks and computed return. Same reads, no assignment.

Rendering helpers#

<For> and <Show> are two small components for the two things every list-and-condition UI needs to do, written so the reactivity rules above stay easy to follow instead of something you have to hand-roll with .map and useMemo.

<For>#

<For> renders a keyed list bound to a signal:

import { For } from 'hono-preact';
import type { ReadonlySignal } from 'hono-preact';

const Movies = ({ movies }: { movies: ReadonlySignal<Movie[]> }) => (
  <ul>
    <For each={movies} by={(m) => m.id}>
      {(movie, index) => (
        <li>
          {index.value}. {movie.value.title}
        </li>
      )}
    </For>
  </ul>
);

Rows reconcile by key: a key that survives from one render to the next keeps its DOM and component state, and only a key that appears or disappears mounts or unmounts. The default key is the item itself, which is exact for an array of ids like memberIds. Supply by whenever the array holds objects re-created per payload (deserialised loader data, for instance), or every row remounts on each new array because no two objects are ever ===.

The child receives item and index as signals, not plain values. Each is a per-row cell: object-stable for as long as that row's key survives, its .value tracking the current item and position. Read .value where you display the item, same as any other signal. The row render itself runs again with a fresh closure on every list change, so nothing you capture in it can go stale, the cells exist so a row can hand a stable reactive identity down to its own subcomponents and effects rather than passing a value that changes identity every render.

Cell writes are content-compared (shallow), so a re-delivered payload whose row did not change writes nothing: an effect or subcomponent bound to that row's cell stays quiet.

A duplicate key throws, since two rows cannot share one slot of state.

<Show>#

<Show> renders one of two branches based on a signal, in its own boundary:

import { Show } from 'hono-preact';

const Banner = ({ error }: { error: ReadonlySignal<string | null> }) => (
  <Show when={error} fallback={<p>All good.</p>}>
    {(message) => <p role="alert">{message}</p>}
  </Show>
);

children can be a plain node, shown whenever when.value is truthy, or a function that receives the truthy value narrowed to NonNullable. fallback renders when when.value is falsy and defaults to nothing. Either form runs inside its own component boundary, so a signal read inside children re-renders that branch alone, not whatever contains the <Show>.

Rooms example#

Realtime Rooms hands out memberIds (a signal of ids, changing only on join and leave) and member(id) (a signal per member, changing only on that member's presence updates). <For> fits that shape directly, no by needed since the array already holds ids:

import { For } from 'hono-preact';
import { serverRooms } from './board.server.js';

export default function Board() {
  const { memberIds, member } = serverRooms.cursors.useRoom({
    key: { boardId: 'main' },
  });

  return (
    <For each={memberIds}>
      {(id) => {
        const m = member(id.value).value;
        if (!m) return null;
        return (
          <div
            style={`position:absolute;left:${m.state?.x}px;top:${m.state?.y}px`}
          >
            {m.state?.name}
          </div>
        );
      }}
    </For>
  );
}

Membership changes reconcile the rows; a member moving their cursor only touches member(id).value inside the one row for that id.

API reference#

ExportSignatureDescription
For<T>(props: ForProps<T>) => VNodeKeyed list rendering bound to a signal.
Show<C>(props: ShowProps<C>) => VNodeConditional rendering bound to a signal.

ForProps<T>#

type ForProps<T> = {
  each: ReadonlySignal<readonly T[]>;
  by?: (item: T, index: number) => unknown;
  children: (
    item: ReadonlySignal<T>,
    index: ReadonlySignal<number>
  ) => ComponentChildren;
};
PropSignatureDescription
eachReadonlySignal<readonly T[]>The list. Read as a signal, so <For> re-renders when it changes.
by(item: T, index: number) => unknown (optional)Derives a stable, unique key per item. Defaults to the item itself; supply this when items are re-created per payload.
children(item: ReadonlySignal<T>, index: ReadonlySignal<number>) => ComponentChildrenRenders one row. item and index are per-row signal cells, object-stable while the key survives.

ShowProps<C>#

type ShowProps<C> = {
  when: ReadonlySignal<C>;
  fallback?: ComponentChildren;
  children: ComponentChildren | ((value: NonNullable<C>) => ComponentChildren);
};
PropSignatureDescription
whenReadonlySignal<C>The condition. <Show> re-renders when it changes.
fallbackComponentChildren (optional)Rendered when when.value is falsy. Defaults to nothing.
childrenComponentChildren | ((value: NonNullable<C>) => ComponentChildren)Rendered when truthy. A function child receives the narrowed truthy value.