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.
Getting started
Install#
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.
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.
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:
-
Don't read
.valuein a component body. That subscribes the component and re-renders it on every change. Unwrap insidecomputed/useComputed/effect/useSignalEffect, or inside an event handler (where reads are untracked anyway). -
Pass signals straight to JSX —
disabled={form.formState.isSubmitting},<span>{errorMessage}</span>. Preact binds them without involving the component. -
Use
peek()orgetValues()for one-off reads in handlers, so you never accidentally create a subscription.
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 .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.
const form = useForm<Values>({
defaultValues: { email: "", profile: { age: 0 }, tags: [] },
mode: "onTouched",
reValidateMode: "onChange",
criteriaMode: "all",
delayError: 400,
shouldFocusError: true,
shouldUnregister: false,
});
Options#
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 [].
When a field validates for the first time. See the timing matrix.
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.
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.
"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.
On a failed submit, focus the first field (in registration order) that has an error.
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.
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.
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.
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.
<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#
The field's dot-path, forwarded to the element.
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.
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.
Marks the field touched, calls your rules.onBlur,
then validates if the mode says so.
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.
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.
{/* 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 |
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:
Number(raw), with "" mapping to
undefined.
new Date(raw), with "" mapping to
null. min/max
understand the resulting Date and compare by
timestamp.
Arbitrary transform, and it wins over the other two — including on number and range inputs, where it receives the raw string.
{/* 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),
})} />
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 |
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:
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>
</>
);
To show or hide a block based on a value, use
<Show> from
@preact/signals/utils instead of a ternary that reads
.value:
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#
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#
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
valuesbecomeundefined. -
Array fields with no entry collapse to
[], and staleitems.N.*child nodes are dropped. - Paths that were never registered as fields are kept in the model and still submitted.
-
defaultValuesis untouched — so the form goes dirty. This is not a reset.
// ❌ 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.
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
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.
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.
Leave existing error messages on screen.
Leave touchedFields as it is.
Keep isSubmitted / submitCount. Note
that isSubmitted also governs
re-validation timing, so
keeping it keeps the form in "already submitted" mode.
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.
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.
A nested tree mirroring the model's shape:
errors.value.profile?.email?.message.
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.
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.
True while any field has an async validation in flight — the hook for a spinner next to an availability check.
True from the moment handleSubmit's handler
starts until your async onValid settles.
True after the first submit attempt, valid or not. Cleared by
reset unless keepIsSubmitted. Also
flips validation over to reValidateMode.
True only after a submit that passed validation and whose handler resolved without throwing.
How many attempts have been made.
Sets of field names, not nested objects —
dirtyFields.value.has("profile.city"). Handy for
sending only what changed.
The current baseline, which reset(values) moves.
Writable scratch signal the library never touches. See below.
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:
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.
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:
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:
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.
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 |
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" },
});
required → minLength →
maxLength → pattern → min →
max → validate. 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.
type Validator = (
value: FieldValue,
formValues: Values,
signal?: AbortSignal,
) => boolean | string | undefined | Promise<boolean | string | undefined>;
{/* 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:
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.
<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.
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):
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".
<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",
})} />
<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:
<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:
-
modegoverns a field that has never errored, in a form that has never been submitted. -
reValidateModetakes 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.
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#
Validate on demand: one path, several paths, or every registered field when called bare. Resolves to whether everything passed.
Write an error yourself — server-side failures, mostly.
Bypasses delayError, and is cleared by the next
validation run of that field.
Clear one, several, or all errors — and abort in-flight validations, so a late result cannot resurrect the message you just removed.
Focus the field's first attached element.
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 };
}
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" });
}
}
});
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.
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",
});
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) });
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.
{/* 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.
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.
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:
event.preventDefault().-
isSubmitting→true,isSubmitSuccessful→false. - Validate every registered field (or run the resolver once).
-
isSubmitted→true,submitCountincremented — regardless of the outcome. -
Valid →
await onValid(values, event), thenisSubmitSuccessful→true. -
Invalid → focus the first errored field (unless
shouldFocusError: false), thenawait onInvalid?.(errors, event). -
isSubmitting→false, in afinally— it always clears, even if your handler throws.
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:
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);
});
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.
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.
The dot-path, autocompleted and type-checked.
Optional when a provider is above. Without either,
<Field> throws with a message telling you
which is missing.
Exactly the second argument of register.
Element to render in auto-binding mode. Ignored when children is a function.
A function gets { field, fieldState, formState }.
Anything else is rendered inside the auto-bound element —
<option>s, typically.
Every other prop is forwarded to the element:
type, placeholder,
class, rows, ARIA attributes.
{/* 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.
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.
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:
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.
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.
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>
);
}
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.
{fields.value.map((item, index) => (
<div key={item.id}>
<input {...form.register(`items.${index}.name`)} />
</div>
))}
Methods#
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.
Add to the end. Accepts one item or several.
Add to the front.
Add at a position.
Remove one index, several at once, or — with no argument — every row.
Exchange two rows, ids included, so
<For> moves the DOM nodes rather than
rebuilding them.
Relocate a row — the drag-and-drop primitive.
Swap the whole list. All ids are minted fresh, so every row remounts.
Replace one row's value wholesale, keeping its id.
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.
// 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:
<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.
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>
);
}
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.
Identical to control.watch(name?). With
name, one field; without it, the whole model.
The same formState object the control carries.
The imperative core of <Controller>. Use it
to build your own bound components.
The nearest control from <Form> or
<FormProvider>. Throws when there is none.
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>
);
}
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#
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.
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)#
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#
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#
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#
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>
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.
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.
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:
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>
);
}
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.isDirty →
signal (.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
|
useFieldArray → fields array
|
fields → signal, iterate with
<For>
|
formState.dirtyFields → nested object |
dirtyFields → Set<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.
-
Rename every single-field
setValuetosetFieldValue. -
Delete
watchdestructuring — bind the signal into JSX instead. -
Replace
errors.x?.messagereads withgetFieldState+useComputed. -
Swap
fields.mapfor<For each={fields}>(or keep.maponfields.value). -
Drop
<Controller>wrappers around plain<input>s —registerhandles 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.