夯HumGuides
Back to home

Page-level reactivity

Markup that comes alive in the browser without a component behind it is page-level reactivity. The unit is a facet: a signal or a handler bound to a single node of the page’s markup. A signal written as a child or as a prop, or a handler the browser rebuilds to write back to one, is all it takes—the machinery an island uses, applied to a single node.

The same machinery as an island

An island is a marked DOM range, an index into a payload, and a Preact root rendered over that range. A facet is those three pieces with a smaller unit: one text node, or one element with its props and handlers. What you skip is declaring a component, and shipping a client module for it.

  • hum:signal brackets a text node, data-hum-signal names the props to rebind, and data-hum-handler the handlers. All three carry payload indexes, exactly like hum:island, and the payload is embedded only when the page has something to revive.
  • Hydration is scheduled for both, so nothing is live until the task after boot returns.
  • What a facet cannot do is hold state. An island owns a component instance, which survives its region being re-rendered, while a page-level signal is revived from the payload of each response. Component state, hooks, or a value that has to stay live on the client are still island work.

Interactive runtime model

The client-side InteractionManager coordinates DOM ownership, mounting, scheduling and cleanup for islands, partials and bindings. Islands and partials host their nested bindings; a signal outside a host gets a standalone binding range. Each response carries one shared payload graph, so signals keep their identity across these units. Partial navigation routes updates through the same manager.

Server-rendered HTML and a shared payload reach the InteractionManager, which coordinates islands, partials and bindings. Preact and Signals render their interactive DOM. Client navigation sends partial updates through the same manager.
Islands, partials and bindings share one interaction manager and payload graph. Preact and Signals handle rendering and reactive updates.

Bind a signal

As a child it renders its value on the server and stays a live text node; as a prop the matching DOM property follows the signal afterwards. Booleans work the same way, so a checkbox or a details element reflects its signal by itself.

TypeScript
import { signal } from "@preact/signals";
import { add, setFromProp } from "@humjs/core/client";

export default function Checkout() {
  const tickets = signal(2);

  return (
    <form>
      <button type="button" onClick={add(tickets, -1, { min: 1, max: 8 })}>−</button>
      <output>{tickets}</output>
      <button type="button" onClick={add(tickets, 1, { min: 1, max: 8 })}>+</button>

      <input
        type="number"
        value={tickets}
        onInput={setFromProp(tickets, "value")}
      />
    </form>
  );
}

One tickets signal is behind the text, the field and both buttons, so a change from any of them updates the others. Creating it with the view is on purpose: the server renders its value into that response—so it may come from the request—and the client revives its own copy. Inside an island, where renders repeat, use useSignal instead.

Handlers come from factories

A handler is rebuilt in the browser, so it travels as a factory ID plus the arguments it was called with—a closure cannot be serialized and throws at render time. @humjs/core/client ships add(signal, delta, options), setValue, toggle and setFromProp(signal, key); a handler of your own is a factory too.

TypeScript
// routes/checkout.tsx — alongside the page and server handlers
import { defineFactory } from "@humjs/core/client";
import type { Signal } from "@preact/signals";

/** Resets both options at once; each built-in factory drives one signal. */
const resetOrder = defineFactory(
  (
    tickets: Signal<number>,
    giftWrap: Signal<boolean>,
    defaults: { tickets: number; giftWrap: boolean },
  ) =>
    () => {
      tickets.value = defaults.tickets;
      giftWrap.value = defaults.giftWrap;
    },
);

Hum generates the factory ID from its file path and variable name. Declare it at module scope; exporting is optional. Hum extracts its browser implementation and loads it before restoring handlers. It can share a route file with server handlers, and needs no client-entry import. Pass request-specific values and signals as factory arguments.

Try it

A checkout made of facets

Every control below is a facet on this page’s own markup. The quantity and the gift-wrap flag are signals; the controls call handlers that the browser rebuilt from the payload, including the custom factory behind Reset.

2

Previous: Partial page updates →