preact-signal-hook-forms
preact-signal-hook-forms 0.1.1

Fully-typed forms for Preact. Signal-first, zero re-render.

Contents

Contents

preact-signal-hook-forms

Performant, fully-typed forms for Preact — a react-hook-form analogue built entirely on @preact/signals.

The component body runs once. Every value, error and flag is a signal bound straight to a DOM attribute or text node, so typing into a field updates the input, its error message, the submit button's disabled state and a live JSON preview — without re-rendering a single component. Validation, field arrays, schema resolvers and dot-path types all come along.

zero re-render signal-first dot-path types built-in + schema validation race-safe async tree-shakeable

Getting started

Install#

sh
npm i @dmytromykhailiuk/preact-signal-hook-forms @preact/signals preact
Peer dependency Version Required
preact >=10.11.0 Yes
@preact/signals >=1.2.0 or ^2.0.0 Yes
zod / valibot / yup any recent Only for that resolver

Node ≥ 18. The package ships ESM + CJS with full type declarations; each schema resolver lives behind its own entry point, so an unused schema library never reaches your bundle.

Signals v2

The <For> and <Show> helpers used throughout the field array examples come from @preact/signals/utils, which needs @preact/signals ^2.0.0. Everything else works on v1 too — on v1, iterate with fields.value.map(...) instead.

Quick start#

One hook, one spread per input. Notice there is no state, no useState, and no dependency array anywhere.

tsxThis function body executes exactly once
import { useForm } from "@dmytromykhailiuk/preact-signal-hook-forms";

type Values = { email: string; password: string };

export function LoginForm() {
  const form = useForm<Values>({
    defaultValues: { email: "", password: "" },
    mode: "onBlur",
  });

  const onSubmit = form.handleSubmit(async (values) => {
    // `values` is fully typed: { email: string; password: string }
    await api.login(values);
  });

  return (
    <form onSubmit={onSubmit}>
      {/* the value signal binds straight to the DOM attribute */}
      <input {...form.register("email", { required: "Email is required" })} />

      {/* a signal rendered as a live text node — updates on every keystroke */}
      <p>You typed: {form.watch("email")}</p>

      <input type="password" {...form.register("password", { minLength: 8 })} />

      {/* signals go straight into attributes, no `.value` needed */}
      <button disabled={form.formState.isSubmitting}>Sign in</button>
    </form>
  );
}

Why signals?#

react-hook-form is fast because it is uncontrolled: it dodges re-renders by reading the DOM through refs, which is why watch costs you a render and controlled third-party inputs need <Controller>.

This library arrives at the same destination from the opposite direction. The field value is a signal, and Preact binds signals directly to DOM attributes and text nodes. The value is fully controlled — you can read it, write it, watch it, derive from it — and still nothing re-renders.

What changes What re-renders
A field's value (typing) Nothing — the bound attribute updates
An error appears or clears Nothing — the bound text node updates
Submit starts/ends Nothing — the bound disabled updates
A field array gains/loses a row Nothing, with <For>; the host component, with .map()
A <Controller>'s value Only if you read field.value.value in its render body

The signal rules#

Everything the API hands back is a signal, a callback or a stable reference — never a plain value that changes underneath you. Three rules keep the zero-re-render promise intact:

  1. Don't read .value in a component body. That subscribes the component and re-renders it on every change. Unwrap inside computed / useComputed / effect / useSignalEffect, or inside an event handler (where reads are untracked anyway).
  2. Pass signals straight to JSXdisabled={form.formState.isSubmitting}, <span>{errorMessage}</span>. Preact binds them without involving the component.
  3. Use peek() or getValues() for one-off reads in handlers, so you never accidentally create a subscription.
tsxThe same UI, once wrong and once right
function Bad({ form }) {
  // ❌ subscribes the whole component: every keystroke re-renders it
  const email = form.watch("email").value;
  const error = form.formState.errors.value.email?.message;
  return <><p>{email}</p><span>{error}</span></>;
}

function Good({ form }) {
  // ✅ signals rendered as text nodes: the component never runs again
  const error = useComputed(() => form.getFieldState("email").error.value?.message ?? "");
  return <><p>{form.watch("email")}</p><span>{error}</span></>;
}
Reading is not forbidden

Reading .value in a component body is a deliberate choice, not a bug — sometimes you genuinely want a re-render (to switch layouts, to render a completely different tree). The rule exists so that it is always a choice you made, never one the library made for you.

Reference

useForm#

useForm<Values>(options?) creates a controller that is stable for the component's lifetime. It never changes identity, so it needs no dependency array, no memoisation and no ref.

tsx
const form = useForm<Values>({
  defaultValues: { email: "", profile: { age: 0 }, tags: [] },
  mode: "onTouched",
  reValidateMode: "onChange",
  criteriaMode: "all",
  delayError: 400,
  shouldFocusError: true,
  shouldUnregister: false,
});

Options#

defaultValuesPartial<Values>

The initial model, and the baseline isDirty compares against. Deep-cloned on the way in, so the object you pass is never mutated.

Also the target of reset(): fields whose path is missing from the defaults reset to undefined, and array fields to [].

mode"onChange" | "onBlur" | "onSubmit" | "onTouched" | "all" — default "onSubmit"

When a field validates for the first time. See the timing matrix.

reValidateMode"onChange" | "onBlur" | "onSubmit" — default "onChange"

Takes over once the field already shows an error or the form has been submitted once. The default pair — validate on submit, re-validate on change — means users aren't nagged while typing, but a message disappears the instant the input becomes valid.

resolverResolver<Values>

Schema validation for the whole model. See resolvers.

Configuring a resolver disables built-in rules entirely, including validate — the schema becomes the single source of truth.

criteriaMode"firstError" | "all" — default "firstError"

"firstError" stops at the first failing rule. "all" keeps going and collects every failure into error.types ({ minLength: "Too short", pattern: "Bad format" }), while error.type/error.message still hold the first one.

shouldFocusErrorboolean — default true

On a failed submit, focus the first field (in registration order) that has an error.

shouldUnregisterboolean — default false

When true, unregister(name) also strips the path out of the default values and the model, so an unmounted field leaves nothing behind in the submitted payload. When false the value survives.

delayErrornumber (ms)

Debounces the appearance of a new error. Validation still runs immediately — only the message is held back, so it never flashes mid-keystroke.

Clearing is always instant, an error already on screen updates instantly, and setError bypasses the delay entirely.

Options are read once

useForm keeps the first options object it is given; later renders passing a different one change nothing. This matches react-hook-form, and it is what makes the controller stable. To change validation behaviour at runtime, keep the mode fixed and drive the rules instead — e.g. toggle disabled on a field's rules, or call trigger() yourself.

The control object#

useForm returns the whole FormControl. Its .control property is a self-reference, so form and form.control are the same object — pass either to <Field>, <Controller> or useFieldArray.

Member Signature Purpose
register (name, rules?) => RegisterReturn Props to spread on a DOM element
unregister (name?) => void Drop one, several, or all fields
handleSubmit (onValid, onInvalid?) => (e?) => Promise<void> Validating submit handler
values ReadonlySignal<Values> The whole model, reactive
watch (name?) => ReadonlySignal One field or the whole model
getValues (name?) => value Non-reactive snapshot
setFieldValue (name, value, options?) => void Write one field
setValue (values, options?) => void Replace the whole model
setError (name, error) => void Set an error manually
clearErrors (name?) => void Clear errors, cancel in-flight runs
trigger (name?) => Promise<boolean> Validate on demand
reset (values?, options?) => void Restore the baseline
resetField (name) => void Restore one field
setFocus (name) => void Focus a field's element
getFieldState (name) => FieldState Per-field reactive state
formState FormState<Values> Form-wide reactive state
control FormControl<Values> Self-reference for components

The same object can be built outside a component with createFormControl<Values>(options) — it is pure TypeScript with no Preact hooks involved, which is how the form logic can be tested, or driven from a module-scope singleton, without rendering anything.

tsA form control with no component in sight
import { createFormControl } from "@dmytromykhailiuk/preact-signal-hook-forms";

const control = createFormControl<{ query: string }>({ defaultValues: { query: "" } });

control.setFieldValue("query", "signals");
await control.trigger();
control.getValues();            // { query: "signals" }
control.formState.isValid.value; // true

register#

register(name, rules?) returns the props that connect a DOM element to a field. Spread them and you are done — the element is bound in both directions, validation is wired, and the field is registered for submission.

tsx
<input {...form.register("email", { required: true, pattern: /@/ })} />

Calling register again for the same name is safe and idempotent: it returns fresh props but reuses the same underlying field node, and replaces that field's rules with the ones you just passed. It is the normal thing to do on every render.

What it returns#

namestring

The field's dot-path, forwarded to the element.

valueReadonlySignal<string | number>

The field's display signal, bound directly to the element's value attribute. Nullish model values surface as "", so an empty field renders empty rather than the literal string "undefined".

Read the real model value with getValues(name) or watch(name) — those give you undefined, Date, FileList and friends untouched.

onInput / onChange(event: Event) => void

Both read the element, apply value transforms, write the model, sync sibling elements bound to the same field (a radio group, a mirrored input), call your own rules.onChange, then validate if the mode says so.

onBlur(event: Event) => void

Marks the field touched, calls your rules.onBlur, then validates if the mode says so.

ref(el: FieldElement | null) => void

Attaches the element. For checkbox / radio / file / multi-select — which cannot be driven through the value attribute — the ref installs an effect that pushes the signal into checked/selected instead. It also enables setFocus and the focus-first-error behaviour.

disabledboolean | undefined

Mirrors rules.disabled. A disabled field is skipped by validation entirely.

Every input type#

The same spread works everywhere; the library detects the element and picks the right read/write strategy.

tsxModel type shown in the comment
{/* string */}
<input {...form.register("name")} />
<textarea {...form.register("bio")} />

{/* number | undefined — empty input reads as undefined, not NaN */}
<input type="number" {...form.register("age", { valueAsNumber: true, min: 18 })} />
<input type="range" {...form.register("volume")} min="0" max="100" />

{/* boolean */}
<input type="checkbox" {...form.register("acceptTerms", { required: "Required" })} />

{/* string — the checked radio's `value` wins; spread the same name on each */}
<input type="radio" {...form.register("plan")} value="free" />
<input type="radio" {...form.register("plan")} value="pro" />

{/* string */}
<select {...form.register("country")}>
  <option value="">Choose…</option>
  <option value="ua">Ukraine</option>
</select>

{/* string[] */}
<select multiple {...form.register("skills")}>
  <option value="ts">TypeScript</option>
  <option value="preact">Preact</option>
</select>

{/* FileList | null */}
<input type="file" multiple {...form.register("attachments")} />

{/* Date | null */}
<input type="date" {...form.register("bornAt", { valueAsDate: true })} />
Element Model value Bound through
text / textarea / select string value attribute (the signal)
number / range number | undefined value attribute
checkbox boolean ref effect → checked
radio the checked option's value ref effect → checked
file FileList ref; only clearing can be written back
select multiple string[] ref effect → selected
Number inputs

An empty type="number" field reads as undefined, never NaN — so required catches it as expected. valueAsNumber is implicit for number/range inputs; you only need it on a text input that should still produce a number.

Value transforms#

Three rule options sit between the raw DOM string and the model value. They apply on every change:

valueAsNumberboolean

Number(raw), with "" mapping to undefined.

valueAsDateboolean

new Date(raw), with "" mapping to null. min/max understand the resulting Date and compare by timestamp.

setValueAs(raw: any) => Value

Arbitrary transform, and it wins over the other two — including on number and range inputs, where it receives the raw string.

tsxNormalising input on the way into the model
{/* trim as you go, so `required` can't be satisfied by spaces */}
<input {...form.register("username", {
  setValueAs: (v) => String(v).trim(),
  required: "Pick a username",
})} />

{/* "12,50" → 12.5 */}
<input {...form.register("price", { setValueAs: (v) => Number(String(v).replace(",", ".")) })} />

{/* comma-separated text → string[] */}
<input {...form.register("tags", {
  setValueAs: (v) => String(v).split(",").map((s) => s.trim()).filter(Boolean),
})} />
Transforms are one-way

The element still displays whatever the signal holds, stringified. If setValueAs turns text into an array, the input shows a,b,c — the array's toString(). For anything where display and model diverge meaningfully, use <Controller> and own both sides.

Reading values#

Three ways to read, differing only in reactivity:

API Returns Use it
form.values ReadonlySignal<Values> The whole model, reactive
form.watch(name) ReadonlySignal<FieldValue> One field, reactive
form.watch() ReadonlySignal<Values> Identical to form.values
form.getValues(name?) a plain snapshot Inside handlers — never subscribes
tsxA live debug panel that costs zero renders
import { useComputed } from "@preact/signals";

function DebugPanel({ form }) {
  const json = useComputed(() => JSON.stringify(form.values.value, null, 2));
  return <pre>{json}</pre>;
}

// One-off read inside a handler — no subscription created:
function onPreview(form) {
  const email = form.getValues("email");
  const all = form.getValues();
  console.log(email, all);
}

Because watch returns a signal you can derive anything from it without touching the component:

tsxDerived UI, still no re-render
const password = form.watch("password");

const strength = useComputed(() => {
  const v = password.value ?? "";
  const score = [/[a-z]/, /[A-Z]/, /\d/, /[^\w]/].filter((r) => r.test(v)).length;
  return v.length < 8 ? "weak" : score >= 3 ? "strong" : "medium";
});

return (
  <>
    <input type="password" {...form.register("password")} />
    <span class={strength}>Strength: {strength}</span>
  </>
);
Conditional rendering without re-rendering

To show or hide a block based on a value, use <Show> from @preact/signals/utils instead of a ternary that reads .value:

tsx
import { Show } from "@preact/signals/utils";

const wantsInvoice = form.watch("wantsInvoice");

<Show when={wantsInvoice}>
  {() => <input {...form.register("vatNumber", { required: "VAT number required" })} />}
</Show>

Writing values#

Different from react-hook-form

In react-hook-form, setValue writes one field. Here that method is setFieldValue, and setValue replaces the whole model. This is the single most common porting mistake.

setFieldValue#

ts
form.setFieldValue("email", "hi@example.com");
form.setFieldValue("profile.address.city", "Kyiv", { shouldValidate: true, shouldDirty: true });
form.setFieldValue("items.2.qty", 5, { shouldTouch: true });
Option Effect
shouldValidate Validate this field right after writing.
shouldDirty Reserved for parity — dirtiness here is derived (value ≠ default), so a write that genuinely differs is dirty either way.
shouldTouch Mark the field touched, as if the user had blurred it.

The write goes into the signal and is pushed into any attached elements, so checkboxes, radios and multi-selects follow along.

setValue#

setValue(values, options?) swaps the entire model out. It is a wholesale replacement, not a merge:

  • Paths missing from values become undefined.
  • Array fields with no entry collapse to [], and stale items.N.* child nodes are dropped.
  • Paths that were never registered as fields are kept in the model and still submitted.
  • defaultValues is untouched — so the form goes dirty. This is not a reset.
tsMerge deliberately, if a merge is what you want
// ❌ wipes every field not present in the patch
form.setValue({ email: "new@example.com" });

// ✅ explicit merge over the current snapshot
form.setValue({ ...form.getValues(), email: "new@example.com" });

// ✅ or write the one field
form.setFieldValue("email", "new@example.com");

reset & resetField#

reset(values?, options?) restores the model to defaultValues — or to values, which then becomes the new baseline that isDirty compares against. It also clears errors and touched state, resets the submit flags, aborts in-flight validations, and realigns every field array.

ts
form.reset();                                  // back to the original defaults, pristine
form.reset(serverData);                        // adopt server data as the new baseline
form.reset(serverData, { keepDirty: true });   // background refetch, keep unsaved edits
form.reset(undefined, { keepValues: true });   // clear errors/touched, keep what's typed
form.reset(form.getValues());                  // "saved!" — current values become pristine
keepValuesboolean

Leave the values alone; only the flags, errors and touched state are cleared. The classic "the save succeeded, now mark it clean" move — though reset(form.getValues()) is usually what you actually want, since that moves the baseline too.

keepDirtyboolean

Preserve in-progress edits through the reset: every field dirty against the old defaults keeps its current value, while clean fields adopt the new ones. Kept fields stay dirty against the new baseline.

This includes field arrays: structural changes and item edits survive, except edits to items the new defaults no longer contain.

keepErrorsboolean

Leave existing error messages on screen.

keepTouchedboolean

Leave touchedFields as it is.

keepIsSubmitted / keepSubmitCountboolean

Keep isSubmitted / submitCount. Note that isSubmitted also governs re-validation timing, so keeping it keeps the form in "already submitted" mode.

tsxLoad-then-populate, the common async case
const form = useForm<Profile>({ defaultValues: { name: "", email: "" } });

useEffect(() => {
  let cancelled = false;
  api.getProfile().then((profile) => {
    // The fetched profile becomes the pristine baseline, so `isDirty`
    // now means "the user changed something since it loaded".
    if (!cancelled) form.reset(profile);
  });
  return () => { cancelled = true; };
}, []);

resetField(name) is the single-field version: restore that field to its default, clear its error, un-touch it, abort its pending validation. It leaves the rest of the form alone.

Field array ids are regenerated

reset re-creates the child field nodes of every array, so useFieldArray mints fresh ids. Never persist those ids outside the form — they identify a row for <For>, nothing more.

Form state#

Every property of form.formState is a signal. Drop them into JSX directly.

errorsReadonlySignal<FieldErrors<Values>>

A nested tree mirroring the model's shape: errors.value.profile?.email?.message.

isDirtyReadonlySignal<boolean>

True when at least one field differs from its default. Fully derived — edit a field back to its original value and the form is clean again, automatically.

isValidReadonlySignal<boolean>

True when no registered field currently holds an error. This is "nothing has failed yet", not "everything has passed" — a pristine form with mode: "onSubmit" reports true before anything ran. Call trigger() if you need a verdict.

isValidatingReadonlySignal<boolean>

True while any field has an async validation in flight — the hook for a spinner next to an availability check.

isSubmittingReadonlySignal<boolean>

True from the moment handleSubmit's handler starts until your async onValid settles.

isSubmittedReadonlySignal<boolean>

True after the first submit attempt, valid or not. Cleared by reset unless keepIsSubmitted. Also flips validation over to reValidateMode.

isSubmitSuccessfulReadonlySignal<boolean>

True only after a submit that passed validation and whose handler resolved without throwing.

submitCountReadonlySignal<number>

How many attempts have been made.

dirtyFields / touchedFieldsReadonlySignal<Set<string>>

Sets of field names, not nested objects — dirtyFields.value.has("profile.city"). Handy for sending only what changed.

defaultValuesReadonlySignal<Partial<Values>>

The current baseline, which reset(values) moves.

sharedSignal<{ [key: string]: any }>

Writable scratch signal the library never touches. See below.

tsxA submit bar wired entirely with signals
import { useComputed } from "@preact/signals";

const { isDirty, isSubmitting, isValidating, submitCount } = form.formState;
const busy = useComputed(() => isSubmitting.value || isValidating.value);
const canSave = useComputed(() => isDirty.value && !busy.value);

return (
  <div class="bar">
    <button disabled={useComputed(() => !canSave.value)}>Save</button>
    <button type="button" disabled={useComputed(() => !isDirty.value)} onClick={() => form.reset()}>
      Discard
    </button>
    <Show when={isDirty}>{() => <span>Unsaved changes</span>}</Show>
    <span>Attempts: {submitCount}</span>
  </div>
);

Sending only what the user actually changed:

ts
const onSubmit = form.handleSubmit(async (values) => {
  const changed = form.formState.dirtyFields.peek();
  const patch = Object.fromEntries(
    [...changed].map((name) => [name, form.getValues(name as any)]),
  );
  await api.patch(patch);
  form.reset(values); // the saved values are the new pristine baseline
});

getFieldState#

getFieldState(name) returns five signals scoped to one field:

Property Type Meaning
error ReadonlySignal<FieldError | undefined> { type, message, types? }
isDirty ReadonlySignal<boolean> Differs from its default
isTouched ReadonlySignal<boolean> Has been blurred
isValidating ReadonlySignal<boolean> Async validation in flight
invalid ReadonlySignal<boolean> error !== undefined

Showing errors#

Build one small component and reuse it for every field. Because it renders signals, it mounts once and never runs again.

tsxThe reusable field row you will write once per project
import { useComputed } from "@preact/signals";
import type { FormControl, FieldPath, FieldValues } from "@dmytromykhailiuk/preact-signal-hook-forms";

export function FieldRow<V extends FieldValues>(props: {
  form: FormControl<V>;
  name: FieldPath<V>;
  label: string;
  children: preact.ComponentChildren;
}) {
  const state = props.form.getFieldState(props.name);
  const message = useComputed(() => state.error.value?.message ?? "");
  const invalid = useComputed(() => (state.error.value ? "true" : "false"));

  return (
    <div class="row">
      <label for={props.name}>{props.label}</label>
      {props.children}
      <span class="error" role="alert" aria-hidden={useComputed(() => (state.error.value ? "false" : "true"))}>
        {message}
      </span>
    </div>
  );
}

// usage
<FieldRow form={form} name="email" label="Email">
  <input id="email" {...form.register("email", { required: "Required" })} />
</FieldRow>

With criteriaMode: "all" you can list every failing rule at once:

tsx
const messages = useComputed(() => {
  const types = form.getFieldState("password").error.value?.types;
  return types ? Object.values(types).filter((m) => typeof m === "string") : [];
});

<ul><For each={messages}>{(m) => <li>{m}</li>}</For></ul>

Or summarise the whole form in one place:

tsxAn error summary that focuses the offending field
const summary = useComputed(() => {
  const flat: Array<{ name: string; message: string }> = [];
  const walk = (node: any, prefix: string) => {
    for (const [key, value] of Object.entries(node ?? {})) {
      const path = prefix ? `${prefix}.${key}` : key;
      if (value && typeof value === "object" && "type" in value) {
        flat.push({ name: path, message: (value as any).message ?? "Invalid" });
      } else if (value && typeof value === "object") {
        walk(value, path);
      }
    }
  };
  walk(form.formState.errors.value, "");
  return flat;
});

<For each={summary}>
  {(e) => (
    <li><button type="button" onClick={() => form.setFocus(e.name as any)}>{e.message}</button></li>
  )}
</For>

shared#

formState.shared is a writable signal the library never reads or writes. It exists so distant parts of a form that already hold the control can exchange ad-hoc state without a second context.

tsxA wizard step, shared without extra plumbing
form.formState.shared.value = { step: 1, currency: "UAH" };

// anywhere that has the control:
const step = useComputed(() => form.formState.shared.value?.step ?? 1);

<Show when={useComputed(() => step.value === 2)}>
  {() => <BillingFields form={form} />}
</Show>

// and a validator can read it too:
form.register("vatNumber", {
  validate: (v) => form.formState.shared.peek()?.currency !== "EUR" || !!v || "VAT required in the EU",
});

Validation

Validation#

Two mutually exclusive strategies: per-field rules passed to register, or a schema resolver covering the whole model. Configuring a resolver switches the built-in rules off completely.

Built-in rules#

Every rule accepts either a bare value or a { value, message } pair. Without a message, error.message is "" and only error.type tells you what failed.

Rule Type Applies to Fails when
required boolean | string | { value, message } any value is undefined, null, "", false, or []
minLength number | { value, message } strings value.length < n
maxLength number | { value, message } strings value.length > n
pattern RegExp | { value, message } strings !regex.test(value)
min number | string | { value, message } numbers, dates, numeric strings value below the bound
max number | string | { value, message } numbers, dates, numeric strings value above the bound
validate fn | Record<string, fn> any returns false or a string
deps path | path[] re-validates other fields
disabled boolean skips validation entirely
tsxBare values and message pairs, side by side
form.register("email", {
  required: "Email is required",                                  // string ⇒ the message
  pattern: { value: /^[^@\s]+@[^@\s]+\.\w+$/, message: "Invalid email" },
  maxLength: { value: 254, message: "Too long" },
});

form.register("age", {
  valueAsNumber: true,
  required: true,                                                 // message will be ""
  min: { value: 18, message: "Must be 18 or older" },
  max: { value: 120, message: "Really?" },
});

form.register("bornAt", {
  valueAsDate: true,
  min: { value: "1900-01-01", message: "Before recorded history" },
  max: { value: new Date().toISOString().slice(0, 10), message: "No time travellers" },
});
Rule evaluation order

requiredminLengthmaxLengthpatternminmaxvalidate. Length and pattern rules only run on strings; min/max only on numbers, dates and numeric strings. Every rule after required is skipped when the value is empty, so an optional field is never scolded for being blank.

With criteriaMode: "firstError" (the default) the chain stops at the first failure; with "all" it runs to the end and collects everything into error.types.

Custom validators#

A validator receives the field value, the whole model, and an AbortSignal. Return true or undefined when valid; return a string (or false) when not.

ts
type Validator = (
  value: FieldValue,
  formValues: Values,
  signal?: AbortSignal,
) => boolean | string | undefined | Promise<boolean | string | undefined>;
tsxSingle function, and a named record
{/* one check, one message */}
<input {...form.register("confirmPassword", {
  validate: (value, values) => value === values.password || "Passwords do not match",
})} />

{/* several named checks — the failing key becomes error.type */}
<input {...form.register("username", {
  validate: {
    noSpaces:  (v) => !v.includes(" ") || "No spaces allowed",
    lowercase: (v) => v === v.toLowerCase() || "Must be lowercase",
    notAdmin:  (v) => v !== "admin" || "Reserved name",
    minWords:  (v) => v.length >= 3 || "At least 3 characters",
  },
})} />

With a record, error.type is the key that failed — which makes per-rule styling and translation straightforward:

tsx
const messages: Record<string, string> = {
  required: t("field.required"),
  noSpaces: t("username.noSpaces"),
  lowercase: t("username.lowercase"),
};
const text = useComputed(() => {
  const err = form.getFieldState("username").error.value;
  return err ? (messages[err.type] ?? err.message ?? "") : "";
});

Async validators & AbortSignal#

Validators may be async, and the library is race-safe: when a newer run starts while an older one is still in flight, the stale run's result is discarded and its AbortSignal fires. Pass that signal to fetch and the server request is genuinely cancelled, not merely ignored.

tsxAvailability check with real cancellation
<input {...form.register("username", {
  required: "Pick a username",
  minLength: { value: 3, message: "Too short" },
  validate: async (value, _values, signal) => {
    if (!value || value.length < 3) return true;      // let the cheap rules speak first
    const res = await fetch(`/api/username?u=${encodeURIComponent(value)}`, { signal });
    const { taken } = await res.json();
    return taken ? "Already taken" : true;
  },
})} />

{/* isValidating is a signal too */}
<Show when={form.getFieldState("username").isValidating}>
  {() => <span class="spinner" />}
</Show>

The signal aborts whenever the run stops mattering: a newer validation started, the field was reset, its errors were cleared, it was unregistered, or a field array realigned it away. A validator that rejects after its signal aborted — exactly what fetch does on abort — ends quietly, with no unhandled rejection and no error written.

Debouncing

Cancellation is not throttling: every keystroke still starts a request. Combine delayError (for the message) with your own debounce inside the validator (for the traffic):

ts
const sleep = (ms: number, signal?: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const t = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")); });
  });

validate: async (value, _values, signal) => {
  await sleep(300, signal);        // superseded runs reject here and end quietly
  const res = await fetch(`/api/check?u=${value}`, { signal });
  return (await res.json()).ok || "Already taken";
}

Cross-field validation & deps#

A validator sees the whole model, so comparing fields is easy. The subtlety is when the comparison re-runs: editing password does not, by itself, re-validate confirmPassword. That is what deps is for — "when this field changes, re-validate those".

tsxWithout deps, the confirm error goes stale
<input type="password" {...form.register("password", {
  required: "Required",
  minLength: { value: 8, message: "At least 8 characters" },
  deps: ["confirmPassword"],          // ← keeps the match check honest
})} />

<input type="password" {...form.register("confirmPassword", {
  validate: (v, values) => v === values.password || "Passwords do not match",
})} />
tsxA date range, kept consistent in both directions
<input type="date" {...form.register("startsAt", {
  valueAsDate: true,
  required: "Pick a start date",
  deps: ["endsAt"],
})} />

<input type="date" {...form.register("endsAt", {
  valueAsDate: true,
  deps: ["startsAt"],
  validate: (end, values) =>
    !end || !values.startsAt || end >= values.startsAt || "End must come after start",
})} />

Conditional requirements read the model the same way:

tsxRequired only when another field says so
<input type="checkbox" {...form.register("needsInvoice", { deps: ["vatNumber"] })} />

<input {...form.register("vatNumber", {
  validate: (v, values) => !values.needsInvoice || !!v || "VAT number is required for invoices",
})} />

When validation runs#

Two settings decide the timing, and which one applies depends on the field's history:

  • mode governs a field that has never errored, in a form that has never been submitted.
  • reValidateMode takes over as soon as the field shows an error or the form has been submitted once.
mode Validates on change Validates on blur Feels like
"onSubmit" (default) No No Silent until you submit
"onBlur" No Yes Checks when you leave a field
"onChange" Yes No Immediate, can nag
"onTouched" Only after first blur Yes Polite: quiet first, live afterwards
"all" Yes Yes Maximally eager
reValidateMode After an error is showing, re-checks on
"onChange" (default) Every keystroke — the message clears the moment it becomes valid
"onBlur" Leaving the field
"onSubmit" The next submit only

The defaults (onSubmit + onChange) are the recommended pairing for most forms: nobody is told they're wrong before they've finished typing, and once they are told, the message vanishes as soon as it stops being true. For long forms where a late surprise is expensive, mode: "onTouched" is the gentler upgrade.

Same rules for controlled fields

useController and <Controller> route their onChange/onBlur through the exact same decision logic, so a third-party component validates on identical timing to a native input.

Manual control#

trigger(name?) => Promise<boolean>

Validate on demand: one path, several paths, or every registered field when called bare. Resolves to whether everything passed.

setError(name, { type, message }) => void

Write an error yourself — server-side failures, mostly. Bypasses delayError, and is cleared by the next validation run of that field.

clearErrors(name?) => void

Clear one, several, or all errors — and abort in-flight validations, so a late result cannot resurrect the message you just removed.

setFocus(name) => void

Focus the field's first attached element.

tsxWizard: validate one step before advancing
async function nextStep() {
  const ok = await form.trigger(["firstName", "lastName", "email"]);
  if (!ok) {
    form.setFocus("firstName");
    return;
  }
  form.formState.shared.value = { ...form.formState.shared.peek(), step: 2 };
}
tsxMapping a 422 back onto fields
const onSubmit = form.handleSubmit(async (values) => {
  try {
    await api.register(values);
    form.reset(values);
  } catch (err) {
    if (err.status === 422) {
      // { email: "Already registered", username: "Reserved" }
      for (const [name, message] of Object.entries(err.fields)) {
        form.setError(name as any, { type: "server", message: String(message) });
      }
      form.setFocus(Object.keys(err.fields)[0] as any);
    } else {
      form.setError("root" as any, { type: "server", message: "Something went wrong" });
    }
  }
});
Server errors are transient

An error set with setError lives in the same slot as a validation error, so the next run for that field overwrites it. With the default reValidateMode: "onChange", a server message disappears as soon as the user edits the field — usually the behaviour you want, but worth knowing before you go looking for the bug.

Schema resolvers#

A resolver validates the whole model at once against a schema. Each adapter lives behind its own entry point, so the schema library never lands in a bundle that doesn't use it.

tsxZod
import { useForm } from "@dmytromykhailiuk/preact-signal-hook-forms";
import { zodResolver } from "@dmytromykhailiuk/preact-signal-hook-forms/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  email: z.string().email("Invalid email"),
  age: z.number().min(18, "Must be 18 or older"),
  profile: z.object({ city: z.string().min(1, "Required") }),
  tags: z.array(z.string()).min(1, "Pick at least one"),
});

type Values = z.infer<typeof schema>;

const form = useForm<Values>({
  resolver: zodResolver(schema),
  defaultValues: { email: "", age: 0, profile: { city: "" }, tags: [] },
  mode: "onBlur",
});
tsxValibot and Yup, same shape
import { valibotResolver } from "@dmytromykhailiuk/preact-signal-hook-forms/resolvers/valibot";
import * as v from "valibot";

const schema = v.object({ email: v.pipe(v.string(), v.email("Invalid email")) });
const form = useForm({ resolver: valibotResolver(schema) });

// ──────────────────────────────────────────────────────────────

import { yupResolver } from "@dmytromykhailiuk/preact-signal-hook-forms/resolvers/yup";
import * as yup from "yup";

const schema = yup.object({ email: yup.string().email("Invalid email").required("Required") });
const form = useForm({ resolver: yupResolver(schema) });
A resolver replaces the built-in rules

With a resolver configured, required, pattern, validate and the rest are not evaluated. Keep passing valueAsNumber / valueAsDate / setValueAs though — those are value transforms, not validation, and the schema needs the model in the right shape.

tsx
{/* z.number() would reject the string "42" — the transform is still required */}
<input type="number" {...form.register("age", { valueAsNumber: true })} />

Nested paths and array indices come back mapped onto the matching fields — a Zod issue at ["items", 2, "qty"] becomes the error for items.2.qty. When several issues target the same path, the first one wins.

Writing your own resolver

A resolver is just a function. The second argument is an AbortSignal that fires when a newer run supersedes this one — honour it for async work.

ts
import type { Resolver } from "@dmytromykhailiuk/preact-signal-hook-forms";

const serverResolver: Resolver<Values> = async (values, signal) => {
  const res = await fetch("/api/validate", {
    method: "POST",
    body: JSON.stringify(values),
    signal,
  });
  const issues: Array<{ path: string; message: string }> = await res.json();

  if (!issues.length) return { values, errors: {} };

  const errors: any = {};
  for (const issue of issues) {
    // build the nested shape: "profile.city" → errors.profile.city
    const parts = issue.path.split(".");
    let cursor = errors;
    for (const part of parts.slice(0, -1)) cursor = cursor[part] ??= {};
    cursor[parts.at(-1)!] = { type: "server", message: issue.message };
  }
  return { values: {}, errors };
};

The contract: return { values, errors }, where errors is a nested tree of { type, message } leaves mirroring the model's shape, and values is the parsed model (or {} when invalid).

Submitting#

handleSubmit(onValid, onInvalid?) returns an event handler. It prevents the browser default, sets isSubmitting, validates every registered field, then branches.

tsx
const onSubmit = form.handleSubmit(
  async (values, event) => {
    await api.save(values);           // isSubmitting stays true until this settles
  },
  (errors, event) => {
    console.warn("blocked by", errors);
    analytics.track("form_invalid", { fields: Object.keys(errors) });
  },
);

<form onSubmit={onSubmit} noValidate>…</form>

The exact sequence, in order:

  1. event.preventDefault().
  2. isSubmittingtrue, isSubmitSuccessfulfalse.
  3. Validate every registered field (or run the resolver once).
  4. isSubmittedtrue, submitCount incremented — regardless of the outcome.
  5. Valid → await onValid(values, event), then isSubmitSuccessfultrue.
  6. Invalid → focus the first errored field (unless shouldFocusError: false), then await onInvalid?.(errors, event).
  7. isSubmittingfalse, in a finally — it always clears, even if your handler throws.
A throwing handler

If onValid rejects, isSubmitSuccessful stays false and the rejection propagates to the caller. Catch inside the handler if you want to turn a failure into setError — see manual control — otherwise attach a .catch() where you call it.

handleSubmit works without a <form> element too — it accepts no event at all, which is what you want for a modal footer button or a programmatic save:

tsx
const save = form.handleSubmit((values) => api.save(values));

<button type="button" onClick={() => save()} disabled={form.formState.isSubmitting}>
  Save
</button>

{/* also fine: autosave on a timer */}
useSignalEffect(() => {
  if (!form.formState.isDirty.value) return;
  const t = setTimeout(() => void save(), 5000);
  return () => clearTimeout(t);
});
Disabled fields and validation

A field registered with disabled: true is skipped by validation entirely — but its value still appears in getValues() and in the submitted payload. To keep a value out of the payload, unregister it with shouldUnregister: true on the form.

Declarative API

Components#

register covers native inputs. These four components cover the rest: a form wrapper, a declarative field, a bridge to controlled third-party components, and a context provider.

<Form>#

An optional <form> wrapper that wires handleSubmit, sets noValidate, and shares the control through context so descendants need no prop drilling. Any other <form> attribute passes through.

tsx
import { Form, Field } from "@dmytromykhailiuk/preact-signal-hook-forms";

<Form
  control={form.control}
  onSubmit={(values) => api.save(values)}
  onInvalid={(errors) => console.warn(errors)}
  class="stack"
  autocomplete="off"
>
  <Field name="email" as="input" type="email" rules={{ required: "Required" }} />
  <Field name="bio" as="textarea" rows={4} />
  <button>Save</button>
</Form>

<Field>#

Declarative register. It takes the control from its control prop or from a surrounding <Form> / <FormProvider>, and supports two styles.

nameFieldPath<Values>

The dot-path, autocompleted and type-checked.

controlFormControl<Values>

Optional when a provider is above. Without either, <Field> throws with a message telling you which is missing.

rulesRegisterOptions

Exactly the second argument of register.

as"input" | "textarea" | "select" — default "input"

Element to render in auto-binding mode. Ignored when children is a function.

childrenrender-prop | ComponentChildren

A function gets { field, fieldState, formState }. Anything else is rendered inside the auto-bound element — <option>s, typically.

…restany

Every other prop is forwarded to the element: type, placeholder, class, rows, ARIA attributes.

tsxAuto-binding, then the render prop
{/* auto-binding — the element is created for you */}
<Field name="email" as="input" type="email" placeholder="you@example.com"
       rules={{ required: "Required" }} />

<Field name="plan" as="select">
  <option value="free">Free</option>
  <option value="pro">Pro</option>
</Field>

{/* render prop — full control over the markup, plus the field's state */}
<Field name="email" rules={{ required: "Required" }}>
  {({ field, fieldState }) => (
    <label class="row">
      <span>Email</span>
      <input {...field} type="email" aria-invalid={useComputed(() => String(!!fieldState.error.value))} />
      <em>{useComputed(() => fieldState.error.value?.message ?? "")}</em>
    </label>
  )}
</Field>

<Controller>#

For components that can't take a spread of DOM props — a date picker, a rich text editor, a design-system <Select>. It hands you a field object whose value is a signal, plus onChange, onBlur and ref.

tsx
import { Controller } from "@dmytromykhailiuk/preact-signal-hook-forms";

<Controller
  control={form.control}
  name="color"
  rules={{ required: "Pick a colour" }}
  defaultValue="#5B4BD6"
  render={({ field, fieldState }) => (
    <>
      {/* .value.value unwraps the signal — a plain value for a plain component */}
      <ColorPicker
        value={field.value.value}
        onChange={field.onChange}
        onBlur={field.onBlur}
      />
      <span>{useComputed(() => fieldState.error.value?.message ?? "")}</span>
    </>
  )}
/>

field.onChange accepts either a raw value or an Event (it reads event.target.value when it sees one), marks the field dirty, and applies the same validate-on-change rules as a native input. field.onBlur marks it touched and validates per the blur rules.

The one place a re-render can appear

Reading field.value.value inside render subscribes that render function — so this component (and only this component) re-renders on each change. That is the price of adapting a component that wants a plain value.

If the third-party component accepts a signal, or you can bind through an attribute, pass field.value itself and the re-render disappears:

tsx
render={({ field }) => (
  <input value={field.value} onInput={field.onChange} onBlur={field.onBlur} ref={field.ref} />
)}

<FormProvider> & useFormContext#

Share one control with an arbitrarily deep tree. <Form> already provides it; <FormProvider> is for when you don't want the <form> element.

tsx
import { FormProvider, useFormContext } from "@dmytromykhailiuk/preact-signal-hook-forms";

function Checkout() {
  const form = useForm<CheckoutValues>({ defaultValues });
  return (
    <FormProvider control={form.control}>
      <AddressSection />
      <PaymentSection />
    </FormProvider>
  );
}

function AddressSection() {
  // typed by you — context cannot infer the model
  const form = useFormContext<CheckoutValues>();
  return (
    <fieldset>
      <input {...form.register("address.street", { required: "Required" })} />
      <input {...form.register("address.city", { required: "Required" })} />
    </fieldset>
  );
}

useFormContext throws a descriptive error when no provider is above it, so a forgotten wrapper fails loudly rather than silently doing nothing.

Field arrays#

useFieldArray({ control, name }) manages a dynamic list. The returned object is created once per (control, name) and never re-created, and fields is a ReadonlySignal that recomputes only on structural changes — adding, removing, moving. Editing a value inside a row never touches it.

tsxWith <For>, even adding a row re-renders nothing
import { useForm, useFieldArray } from "@dmytromykhailiuk/preact-signal-hook-forms";
import { For } from "@preact/signals/utils";

type Values = { items: Array<{ name: string; qty: number }> };

function Invoice() {
  const form = useForm<Values>({ defaultValues: { items: [{ name: "", qty: 1 }] } });
  const { fields, append, remove, move, swap, insert, clear } = useFieldArray<Values>({
    control: form.control,
    name: "items",
  });

  return (
    <form onSubmit={form.handleSubmit((v) => console.log(v))}>
      <For each={fields} getKey={(f) => f.id}>
        {(item, index) => (
          <div class="row">
            <input {...form.register(`items.${index}.name`, { required: "Name required" })} />
            <input type="number" {...form.register(`items.${index}.qty`, {
              valueAsNumber: true,
              min: { value: 1, message: "At least 1" },
            })} />
            <button type="button" onClick={() => remove(index)}>×</button>
            <button type="button" onClick={() => move(index, Math.max(0, index - 1))}>↑</button>
          </div>
        )}
      </For>

      <button type="button" onClick={() => append({ name: "", qty: 1 })}>Add item</button>
      <button type="button" onClick={clear}>Clear all</button>
      <button>Submit</button>
    </form>
  );
}
Prefer plain JSX?

fields.value.map(...) works too. Reading .value subscribes the host component, so it re-renders on structural changes only — never on value edits. That is a perfectly reasonable trade for a short list.

tsx
{fields.value.map((item, index) => (
  <div key={item.id}>
    <input {...form.register(`items.${index}.name`)} />
  </div>
))}

Methods#

fieldsReadonlySignal<Array<Item & { id: string }>>

The rows, each with a stable id for keying. A snapshot of the values, not a live binding — bind inputs via register(`items.${i}.field`), not via item.field.

append(value: Item | Item[]) => void

Add to the end. Accepts one item or several.

prepend(value: Item | Item[]) => void

Add to the front.

insert(index: number, value: Item | Item[]) => void

Add at a position.

remove(index?: number | number[]) => void

Remove one index, several at once, or — with no argument — every row.

swap(a: number, b: number) => void

Exchange two rows, ids included, so <For> moves the DOM nodes rather than rebuilding them.

move(from: number, to: number) => void

Relocate a row — the drag-and-drop primitive.

replace(values: Item[]) => void

Swap the whole list. All ids are minted fresh, so every row remounts.

update(index: number, value: Item) => void

Replace one row's value wholesale, keeping its id.

clear() => void

Empty the array outright, dropping the default items along with anything append added. To go back to the defaults instead, call form.reset().

Validating a list#

Per-row rules go on the row's fields. Rules about the array as a whole — length, uniqueness, totals — go on the array path itself, which you can register on a hidden input or simply validate via trigger.

tsxUniqueness across rows, plus a minimum length
// a rule on the whole array
form.register("items", {
  validate: {
    notEmpty: (items) => (items?.length ?? 0) > 0 || "Add at least one item",
    unique: (items) => {
      const names = (items ?? []).map((i: any) => i.name?.trim().toLowerCase());
      return new Set(names).size === names.length || "Item names must be unique";
    },
    underBudget: (items, values) => {
      const total = (items ?? []).reduce((s: number, i: any) => s + i.qty * i.price, 0);
      return total <= values.budget || `Total ${total} exceeds the budget`;
    },
  },
});

// surface it like any other field error
const arrayError = useComputed(() => form.getFieldState("items").error.value?.message ?? "");
<p class="error">{arrayError}</p>

A per-row rule that needs to see its siblings can find its own index in the path:

tsx
<input {...form.register(`items.${index}.name`, {
  required: "Required",
  validate: (value, values) =>
    values.items.filter((i) => i.name === value).length === 1 || "Duplicate name",
  deps: ["items"],
})} />

Nested arrays#

Arrays inside arrays work by composing paths. Give each nesting level its own component so the inner useFieldArray gets a stable name.

tsxorders[i].lines[j].sku
function Orders({ form }: { form: FormControl<Values> }) {
  const orders = useFieldArray<Values>({ control: form.control, name: "orders" });
  return (
    <>
      <For each={orders.fields} getKey={(o) => o.id}>
        {(_order, i) => <OrderLines form={form} index={i} />}
      </For>
      <button type="button" onClick={() => orders.append({ lines: [] })}>Add order</button>
    </>
  );
}

function OrderLines({ form, index }: { form: FormControl<Values>; index: number }) {
  const lines = useFieldArray<Values>({
    control: form.control,
    name: `orders.${index}.lines` as any,
  });
  return (
    <fieldset>
      <For each={lines.fields} getKey={(l) => l.id}>
        {(_line, j) => (
          <input {...form.register(`orders.${index}.lines.${j}.sku`, { required: "SKU?" })} />
        )}
      </For>
      <button type="button" onClick={() => lines.append({ sku: "" })}>Add line</button>
    </fieldset>
  );
}
Two things to know

Ids are not data. reset and replace mint fresh ids, because the underlying child field nodes are re-created. Use them for keying and nothing else — never persist them or send them to a server.

Removing a row shifts the paths below it. items.2 becomes items.1. The library realigns the field nodes for you, so errors and dirty state follow the rows correctly — but any path string you cached yourself is now pointing at a different row.

Other hooks#

Three thin conveniences for components that hold a control but not the original form. They create nothing new — they just read what the control already exposes, which is why they are safe to call anywhere, in any order.

useWatch({ control, name? }) => ReadonlySignal

Identical to control.watch(name?). With name, one field; without it, the whole model.

useFormState({ control }) => FormState<Values>

The same formState object the control carries.

useController({ control, name, rules?, defaultValue? }) => { field, fieldState }

The imperative core of <Controller>. Use it to build your own bound components.

useFormContext<Values>() => FormControl<Values>

The nearest control from <Form> or <FormProvider>. Throws when there is none.

tsxA reusable bound component built on useController
import { useController } from "@dmytromykhailiuk/preact-signal-hook-forms";

function CurrencyInput({ control, name, currency = "UAH" }) {
  const { field, fieldState } = useController({ control, name, rules: { required: "Required" } });
  const display = useComputed(() =>
    field.value.value == null ? "" : `${field.value.value} ${currency}`,
  );

  return (
    <label>
      <input
        value={display}
        onBlur={field.onBlur}
        onInput={(e) => field.onChange(Number((e.target as HTMLInputElement).value.replace(/\D/g, "")))}
      />
      <em>{useComputed(() => fieldState.error.value?.message ?? "")}</em>
    </label>
  );
}
tsxA summary component that only needs the control
function OrderTotal({ control }: { control: FormControl<Values> }) {
  const items = useWatch({ control, name: "items" });
  const total = useComputed(() =>
    (items.value ?? []).reduce((sum, i) => sum + (i.qty ?? 0) * (i.price ?? 0), 0),
  );
  return <strong>Total: {total}</strong>;
}

Patterns

Recipes#

Complete, copy-ready solutions to the situations that come up in every real form.

Sign-up: async availability, password match, terms#

tsx
type SignUp = {
  username: string;
  email: string;
  password: string;
  confirm: string;
  acceptTerms: boolean;
};

function SignUpForm() {
  const form = useForm<SignUp>({
    defaultValues: { username: "", email: "", password: "", confirm: "", acceptTerms: false },
    mode: "onTouched",
    delayError: 400,
  });

  const onSubmit = form.handleSubmit(async (values) => {
    try {
      await api.signUp(values);
      form.reset();
    } catch (err) {
      form.setError("email", { type: "server", message: err.message });
    }
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("username", {
        required: "Pick a username",
        minLength: { value: 3, message: "At least 3 characters" },
        validate: async (value, _v, signal) => {
          if (!value || value.length < 3) return true;
          const res = await fetch(`/api/username?u=${value}`, { signal });
          return (await res.json()).available || "Already taken";
        },
      })} />

      <input type="email" {...form.register("email", {
        required: "Email is required",
        pattern: { value: /^[^@\s]+@[^@\s]+\.\w+$/, message: "Invalid email" },
      })} />

      <input type="password" {...form.register("password", {
        required: "Required",
        minLength: { value: 8, message: "At least 8 characters" },
        deps: ["confirm"],
      })} />

      <input type="password" {...form.register("confirm", {
        validate: (v, values) => v === values.password || "Passwords do not match",
      })} />

      <label>
        <input type="checkbox" {...form.register("acceptTerms", {
          required: "You must accept the terms",
        })} />
        I accept the terms
      </label>

      <button disabled={form.formState.isSubmitting}>Create account</button>
    </form>
  );
}

A multi-step wizard#

One form, one model, validated a step at a time. The current step lives in formState.shared, so no extra state and no extra context.

tsx
import { Show } from "@preact/signals/utils";

const STEPS = [
  ["firstName", "lastName"],
  ["street", "city", "zip"],
  ["cardNumber", "cvc"],
] as const;

function Wizard() {
  const form = useForm<WizardValues>({ defaultValues, mode: "onTouched" });
  const step = useComputed(() => form.formState.shared.value?.step ?? 0);

  const go = (delta: number) => {
    form.formState.shared.value = {
      ...form.formState.shared.peek(),
      step: Math.max(0, Math.min(STEPS.length - 1, (form.formState.shared.peek()?.step ?? 0) + delta)),
    };
  };

  const next = async () => {
    const current = STEPS[form.formState.shared.peek()?.step ?? 0];
    if (await form.trigger([...current] as any)) go(1);
    else form.setFocus(current[0] as any);
  };

  return (
    <form onSubmit={form.handleSubmit((v) => api.checkout(v))}>
      <Show when={useComputed(() => step.value === 0)}>{() => <NameStep form={form} />}</Show>
      <Show when={useComputed(() => step.value === 1)}>{() => <AddressStep form={form} />}</Show>
      <Show when={useComputed(() => step.value === 2)}>{() => <PaymentStep form={form} />}</Show>

      <nav>
        <button type="button" onClick={() => go(-1)} disabled={useComputed(() => step.value === 0)}>
          Back
        </button>
        <Show when={useComputed(() => step.value < STEPS.length - 1)}>
          {() => <button type="button" onClick={next}>Next</button>}
        </Show>
        <Show when={useComputed(() => step.value === STEPS.length - 1)}>
          {() => <button disabled={form.formState.isSubmitting}>Pay</button>}
        </Show>
      </nav>
    </form>
  );
}

Dependent selects (country → city)#

tsx
function LocationFields({ form }) {
  const country = form.watch("country");

  // Clear the city whenever the country changes — a signal effect, not a render.
  useSignalEffect(() => {
    country.value;                                  // subscribe
    if (form.getValues("city")) form.setFieldValue("city", "");
  });

  const cities = useComputed(() => CITIES[country.value] ?? []);

  return (
    <>
      <select {...form.register("country", { required: "Pick a country" })}>
        <option value="">Choose…</option>
        <option value="ua">Ukraine</option>
        <option value="pl">Poland</option>
      </select>

      <select {...form.register("city", { required: "Pick a city" })}>
        <option value="">Choose…</option>
        <For each={cities}>{(c) => <option value={c.id}>{c.name}</option>}</For>
      </select>
    </>
  );
}

Unsaved-changes guard#

tsx
useSignalEffect(() => {
  if (!form.formState.isDirty.value) return;
  const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; };
  window.addEventListener("beforeunload", warn);
  return () => window.removeEventListener("beforeunload", warn);
});

Autosave a draft to localStorage#

tsx
const form = useForm<Draft>({
  // restore synchronously so the very first paint already has the draft
  defaultValues: JSON.parse(localStorage.getItem("draft") ?? "null") ?? { title: "", body: "" },
});

useSignalEffect(() => {
  const snapshot = JSON.stringify(form.values.value);   // subscribes to the model
  const t = setTimeout(() => localStorage.setItem("draft", snapshot), 500);
  return () => clearTimeout(t);
});

const onSubmit = form.handleSubmit(async (values) => {
  await api.publish(values);
  localStorage.removeItem("draft");
  form.reset({ title: "", body: "" });
});

File upload with size and type checks#

tsx
const MAX = 5 * 1024 * 1024;

<input type="file" accept="image/*" {...form.register("avatar", {
  required: "Choose a file",
  validate: {
    size: (files: FileList) => !files?.[0] || files[0].size <= MAX || "Max 5 MB",
    type: (files: FileList) =>
      !files?.[0] || files[0].type.startsWith("image/") || "Images only",
  },
})} />

{/* a live preview, without re-rendering the form */}
const preview = useComputed(() => {
  const files = form.watch("avatar").value as FileList | null;
  return files?.[0] ? URL.createObjectURL(files[0]) : "";
});
<Show when={preview}>{() => <img src={preview} alt="" />}</Show>
Submitting files

getValues() gives you the FileList itself, so build the request body from it directly: const body = new FormData(); body.append("avatar", values.avatar[0]);. Note that a file input's value cannot be set programmatically — reset can only clear it.

Testing a form#

Because createFormControl has no Preact dependency, the logic can be tested without rendering anything at all.

ts
import { createFormControl } from "@dmytromykhailiuk/preact-signal-hook-forms";
import { expect, test } from "vitest";

test("passwords must match", async () => {
  const control = createFormControl<{ password: string; confirm: string }>({
    defaultValues: { password: "", confirm: "" },
  });

  control.register("confirm", {
    validate: (v, values) => v === values.password || "Passwords do not match",
  });

  control.setFieldValue("password", "hunter2222");
  control.setFieldValue("confirm", "nope");

  expect(await control.trigger()).toBe(false);
  expect(control.getFieldState("confirm").error.value?.message).toBe("Passwords do not match");

  control.setFieldValue("confirm", "hunter2222");
  expect(await control.trigger()).toBe(true);
});

TypeScript#

Give useForm your model and everything downstream follows: field names autocomplete as dot-paths, values are typed per path, and a typo is a compile error rather than a silent undefined.

ts
type Values = {
  email: string;
  profile: { age: number; address: { city: string } };
  items: Array<{ name: string; qty: number }>;
};

const form = useForm<Values>();

form.register("profile.address.city");   // ✅ autocompleted
form.register("profile.address.town");   // ❌ compile error
form.register("items.0.qty");            // ✅ typed as number

const age: number = form.getValues("profile.age");        // ✅ inferred
const city = form.watch("profile.address.city");          // ReadonlySignal<string>

form.setFieldValue("profile.age", "old");                 // ❌ string is not number
Helper Meaning
FieldValues The base constraint — Record<string, any>
FieldPath<V> Every valid dot-path of V
FieldArrayPath<V> Only the paths whose value is an array
FieldPathValue<V, N> The type at path N
RegisterOptions<V, N> Rules, typed against that field's value
FormControl<V> The control object, for props
FieldError / FieldErrors<V> One error / the nested error tree
FormState<V> / FieldState Reactive state shapes
Resolver<V> / ResolverResult<V> The resolver contract
SubmitHandler<V> / SubmitErrorHandler<V> handleSubmit callbacks

Write generic form components by taking the model as a parameter:

tsx
import type { FormControl, FieldPath, FieldValues } from "@dmytromykhailiuk/preact-signal-hook-forms";

interface TextFieldProps<V extends FieldValues> {
  form: FormControl<V>;
  name: FieldPath<V>;      // callers get autocompletion for *their* model
  label: string;
}

export function TextField<V extends FieldValues>({ form, name, label }: TextFieldProps<V>) {
  const error = useComputed(() => form.getFieldState(name).error.value?.message ?? "");
  return (
    <label>
      <span>{label}</span>
      <input {...form.register(name)} />
      <em>{error}</em>
    </label>
  );
}
Template literal paths

Inside a field array, `items.${index}.name` is inferred correctly when index is a number. For deeply dynamic paths TypeScript occasionally needs a nudge — as FieldPath<Values> is the intended escape hatch, not a workaround.

Coming from react-hook-form#

The API is deliberately familiar. The one shift that matters: read .value, or render the signal — don't expect a re-render.

react-hook-form preact-signal-hook-forms
watch("x") → value, re-renders watch("x")signal, no re-render
formState.isDirty → boolean formState.isDirtysignal (.value)
errors.x?.message getFieldState("x").error.value?.message
setValue("x", v) → one field setFieldValue("x", v)
— (no equivalent) setValue(values) → replaces the whole model
getValues() / watch() also form.values — the model as one signal
<Controller> for everything controlled register for native inputs, <Controller> for the rest
register("x"){ onChange, ref, … } also returns a bindable value signal
useFieldArrayfields array fieldssignal, iterate with <For>
formState.dirtyFields → nested object dirtyFieldsSet<string> of paths
useFormState({ control, name }) useFormState({ control }) — no subscription slicing needed

Things that behave identically and need no thought: mode / reValidateMode semantics, every built-in rule and its { value, message } form, criteriaMode, delayError, shouldFocusError, shouldUnregister, handleSubmit(onValid, onInvalid), trigger, setError / clearErrors, reset and its keep* options, and the resolver contract.

Porting checklist
  1. Rename every single-field setValue to setFieldValue.
  2. Delete watch destructuring — bind the signal into JSX instead.
  3. Replace errors.x?.message reads with getFieldState + useComputed.
  4. Swap fields.map for <For each={fields}> (or keep .map on fields.value).
  5. Drop <Controller> wrappers around plain <input>s — register handles them controlled.

Exports#

Hooks

useForm · useFormContext · useWatch · useFormState · useController · useFieldArray

Components

Form · Field · Controller · FormProvider

Core

createFormControl · FormControlContext

Resolvers (separate entry points)

zodResolver from …/resolvers/zod · valibotResolver from …/resolvers/valibot · yupResolver from …/resolvers/yup

Types

FieldValues · FieldPath · FieldArrayPath · FieldPathValue · Path · ArrayPath · PathValue · FieldElement · FieldError · FieldErrors · FieldState · FormState · FormControl · FieldNode · RegisterOptions · RegisterReturn · SetValueOptions · ResetOptions · UseFormOptions · ValidationMode · ReValidateMode · ValidationRule · Validator · Resolver · ResolverResult · SubmitHandler · SubmitErrorHandler · FormProps · FieldProps · FieldRenderProps · ControllerProps · ControllerRenderProps · ControllerField · FormProviderProps · UseControllerProps · UseControllerReturn · UseFieldArrayProps · UseFieldArrayReturn

Full TSDoc ships with the package — hover any of these in your editor for the same reference material inline.