zfb
GitHub repository

Type to search...

to open search from anywhere

Forms

Keep controls uncontrolled by default and opt into the five writable signal adapters.

Uncontrolled by default

Controls use native browser state unless you provide an explicit model. Static value and checked props set initial defaults. Reactive value and checked props are rejected by TypeScript (zfb check) and at render time, except for the display-only value binding below; use a model prop when the control should read and write a signal.

The reserved props are defaultValue, defaultChecked, modelValue, and modelChecked. They do not become HTML attributes. Supplying a model together with its default is an error, as is supplying both value and defaultValue or both checked and defaultChecked.

defaultValue works on an input with omitted type or type text, search, email, url, tel, or password, and on textarea or a single select. defaultChecked works on checkbox and radio inputs. These are initial defaults, not signal bindings. An input type typed as a broad string cannot safely use either default prop because it might resolve to an unsupported type; a union limited to supported literal types can use the matching default. For any other input type, such as color, number, range, date, or time, use a static value attribute when that control supports one. The same input restrictions apply during server rendering, mount, and hydration.

Bind a writable signal

Text inputs, textarea, checkbox, single select, and radio groups are the supported models:

ControlModelInitial server/default form
Text input (including omitted type), search, email, URL, tel, or password inputmodelValue with a writable string signalEscaped value attribute
textareamodelValue with a writable string signalEscaped text content
checkbox inputmodelChecked with a writable boolean signalBoolean checked attribute
single selectmodelValue with a writable string signalselected on the matching static option
radio groupshared modelValue with a writable string-or-null signalchecked on the matching static option; null checks none

The corresponding live events are input for text inputs and textarea, and change for checkbox, select, and radio controls. Radio models are shared across each group. A leading line feed in textarea default content is preserved through HTML parsing.

import { signal } from "@takazudo/zfb/zudo-react";

function Preferences() {
  const displayName = signal("Ada");
  const updatesEnabled = signal(true);
  const delivery = signal<string | null>("email");

  return (
    <form>
      <label>
        Display name
        <input modelValue={displayName} />
      </label>
      <label>
        <input type="checkbox" modelChecked={updatesEnabled} />
        Email me updates
      </label>
      <label>
        <input type="radio" name="delivery" value="email" modelValue={delivery} />
        Email
      </label>
      <label>
        <input type="radio" name="delivery" value="post" modelValue={delivery} />
        Post
      </label>
    </form>
  );
}

Without a model, defaultChecked sets a radio option's initial checked state; it does not require a name or value. Do not combine it with checked.

Each modelled radio in a group needs a static value and the same nonempty name, form association, root, and shared modelValue. Do not bind a radio group through checked; modelValue selects the matching option. Radio option values and select option values must be unique strings. A null radio model means no option is checked. A single-select model must match a static option, except that hydration may record the browser's no-selection state without forcing a choice.

Models must be writable

Every model requires a Signal<T> from signal(). A ReadonlySignal<T> or computed value is not a model, even though both have the shared reactive brand. Signal also carries the required writable brand. This is checked by TypeScript and by server and client runtime validation, including dynamic descriptions created with h.

import type { ReadonlySignal, Signal } from "@takazudo/zfb/zudo-react";
import { computed, signal } from "@takazudo/zfb/zudo-react";

const name = signal("Ada");
const view: ReadonlySignal<string> = name;
const writable: Signal<string> = name;
const label = computed(() => name.value.toUpperCase());

<input modelValue={name} />;
<p>{label}</p>;

Use computed and readonly signals for text or other readable bindings. They remain valid inputs when a child only reads a signal. A readonly type annotation does not erase the writable brand at runtime, but the TypeScript type itself cannot be supplied as a model. Runtime validation rejects models without that writable brand before any model write.

Multiple selection is unsupported in v1. Multiple selects are rejected in island mode and cannot use model or defaultValue. File and contenteditable models, and models for every input type outside the table above (including color, number, range, date, and time), are unsupported. Select, optgroup, and option structure must be static; option labels and option lists are not reactive. A programmatic model or default with no matching option reports ZR_MODEL_VALUE without a partial update.

Display-only controls

A one-way reactive value is supported only on a <textarea> or an <input> whose type is statically omitted or statically one of text | search | email | url | tel | password, and when readonly or disabled is the literal boolean true. Reactive readonly, disabled, or type never qualifies. readonly={false}, disabled={false}, the empty string, and strings such as "readonly" do not qualify. Other input types remain unsupported, and checked stays static.

Pass a ReadonlySignal<string> to value; a computed() result is also a ReadonlySignal<string>. This binding only displays the signal value: it does not write user edits back, use modelValue, or add input, change, or composition listeners. It is useful for porting a Preact display-only field such as <input value={address} readOnly />:

import { computed, signal } from "@takazudo/zfb/zudo-react";

function AddressBar() {
  const address = signal("https://example.test/search?q=ada");
  const displayedAddress = computed(() => address.value);
  return <input type="url" value={displayedAddress} readonly={true} />;
}

At setup, the server-emitted input value attribute or textarea text content remains the reset default. A client-only mount establishes its initial default once, and both paths initialize the live value property. Later signal updates set only the property, so form.reset() restores the initial value for both SSR-hydrated and client-only mounts. Do not combine a reactive value with defaultValue or modelValue; textarea children also conflict with a reactive value. For display outside a form control, use <output> or <pre> instead.

Static numeric values and input events

A numeric input has no model adapter. Use a static initial value and read later edits through on:input. If you clamp or normalize an edit, write the result back to the DOM explicitly; updating a signal alone does not update this uncontrolled input.

import { signal } from "@takazudo/zfb/zudo-react";

function Quantity() {
  const quantity = signal(2);
  return (
    <input
      type="number"
      min="1"
      max="8"
      value={String(quantity.value)}
      on:input={(event) => {
        const raw = Number(event.currentTarget.value);
        const next = Math.min(8, Math.max(1, Number.isFinite(raw) ? raw : 2));
        quantity.value = next;
        event.currentTarget.value = String(next);
      }}
    />
  );
}

The value expression is read when the input is created; later signal writes do not bind that DOM property. This pattern has no model hydration behavior.

Hydration preserves what the user entered

At hydration, the runtime reads and validates every live control value before writing any model. The current DOM wins in one root-wide batch, so typing that happened before activation is preserved. Multiple non-radio controls bound to the same signal must agree. Radio groups reconcile as a group rather than depending on traversal order.

mount creates new controls, so it initializes them from their models or defaults. After activation, adapters skip equal property assignments to preserve caret and selection. A native form reset updates affected models after the browser applies its default action; reset defaults remain the original defaults, not later signal values.

Composition and Japanese input

Text adapters observe compositionstart, compositionend, input.isComposing, and blur. During composition, live input may update the model, but signal notifications do not write back into the composing control. External writes are buffered; when composition ends or the control blurs, the final DOM edit is read as authoritative.

The shared zfb boot path installs capture listeners before island scheduling so the adapter can identify active composition across bundles. A focused control with unknown composition state postpones writes until compositionend or blur; nonfocused controls are safe to update. Synthetic composition events can exercise this state protocol in automation. They do not verify behavior with a real Japanese IME. Real IME behavior remains deferred human verification under DD17.

See also

Revision History

CreatedUpdated