@dmytromykhailiuk/preact-signal-hook-forms
v0.1.1
Published
Performant, fully-typed forms for Preact — a react-hook-form analogue built entirely on @preact/signals. Signal-first, zero re-render.
Downloads
301
Maintainers
Readme
preact-signal-hook-forms
Performant, fully-typed forms for Preact, built entirely on
@preact/signals. A functional analogue of react-hook-form — signal-first, zero re-render.
Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.
- ⚡️ Zero re-render. The component body runs once. Every value, error and flag flows through signals straight to the DOM.
- 🎯 Signal-first. Field state is a signal you can drop anywhere:
<input {...register("email")} />,{form.watch("email")},{errorSignal}. - 🧩 Two APIs. Imperative
register()and declarative<Field>/<Controller>. - 🛡 Fully typed. Dot-path autocompletion (
user.address.city,items.0.name). - ✅ Validation. Built-in rules plus schema resolvers (Zod / Valibot / Yup).
- 🌲 Tiny & tree-shakeable. ESM + CJS, resolvers in separate entry points.
Installation
npm install @dmytromykhailiuk/preact-signal-hook-formsRequires Node.js ≥ 18 and the peer dependencies every Preact app already has:
| Peer dependency | Version |
| ----------------- | --------------------- |
| preact | >=10.11.0 |
| @preact/signals | >=1.2.0 or ^2.0.0 |
Schema libraries (zod, valibot, yup) are optional — install one only if
you use its resolver.
Table of contents
- Quick start
- Why signals?
- Core concepts
[useForm(options)](#useformoptions)[register(name, rules?)](#registername-rules)- Reading values:
values,watch,getValues - Writing values:
setFieldValue,setValue [reset(values?, options?)](#resetvalues-options)[formState](#formstate)- Showing errors
- Validation
- Declarative components
- Dynamic lists —
useFieldArray - Other hooks
- Migrating from react-hook-form
- API reference
- Development
- License
Quick start
import { useForm } from "@dmytromykhailiuk/preact-signal-hook-forms";
function LoginForm() {
// 👇 This function runs exactly ONCE.
const form = useForm<{ email: string; password: string }>({
defaultValues: { email: "", password: "" },
mode: "onBlur",
});
const onSubmit = form.handleSubmit((values) => {
console.log(values); // fully typed { email, password }
});
return (
<form onSubmit={onSubmit}>
{/* value={signal} binds straight to the DOM attribute — no re-render */}
<input {...form.register("email", { required: "Email is required" })} />
{/* the value signal, rendered as a live text node */}
<p>You typed: {form.watch("email")}</p>
<input type="password" {...form.register("password", { minLength: 8 })} />
{/* computed signals go straight into attributes */}
<button disabled={form.formState.isSubmitting}>Sign in</button>
</form>
);
}Why signals?
react-hook-form is fast because it is uncontrolled — it avoids re-renders by reading the DOM through refs. This library reaches the same goal from the other direction: the field value is a signal, and Preact binds signals directly to the DOM (as an attribute or a text node). The value is fully controlled, yet nothing re-renders.
Core concepts
useForm(options)
Creates a form controller that stays stable for the component's lifetime (options are read once, on the first render). All options:
| Option | Type | Default |
| ------------------ | ------------------ | -------- | -------------- | ------------ | ------ | ------------ |
| defaultValues | Partial<Values> | {} |
| mode | "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all" | "onSubmit" |
| reValidateMode | "onChange" | "onBlur" | "onSubmit" | "onChange" |
| resolver | Resolver<Values> | – |
| criteriaMode | "firstError" | "all" | "firstError" |
| shouldFocusError | boolean | true |
| shouldUnregister | boolean | false |
| delayError | number (ms) | – |
delayError debounces the appearance of a new error: validation still runs
immediately, but the message is held back for the given delay so it never
flashes mid-keystroke. Clearing is always instant — the moment the field
becomes valid the error disappears, and a pending message is cancelled.
setError bypasses the delay.
mode decides when a field is validated for the first time;
reValidateMode takes over once the field already shows an error (or the form
was submitted). The default pair — validate on submit, re-validate on every
change — means users aren't nagged while typing, but errors disappear the
moment the input becomes valid.
register(name, rules?)
Returns props to spread onto a DOM element:
<input {...form.register("email", { required: true, pattern: /@/ })} />The returned value is a read-only signal bound directly to the element's
value attribute. Nullish model values surface as "" so an empty field
renders empty (never the string "undefined"); read the actual model value
with getValues(name) or watch(name).
Checkbox / radio / file inputs are driven through the ref (their checked /
files state) — spreading register just works:
<input type="checkbox" {...form.register("acceptTerms", { required: true })} />
<input type="radio" {...form.register("plan")} value="pro" />
<input type="number" {...form.register("age", { valueAsNumber: true, min: 18 })} />Reading values: values, watch, getValues
form.values→ aReadonlySignal<Values>holding the whole model. Render it directly to track the form without a re-render.form.watch(name?)→ aReadonlySignalof one field (or the whole model when called with no argument — the same signal asform.values).form.getValues(name?)→ a plain snapshot (uses.peek(), no subscription). Use it inside event handlers.
<pre>{JSON.stringify(form.values.value, null, 2)}</pre>; // reactive
const email = form.getValues("email"); // one-off readWriting values: setFieldValue, setValue
form.setFieldValue(name, value, options?)→ write one field.form.setValue(values, options?)→ replace the whole model. Paths missing fromvaluesbecomeundefined, array fields collapse to[], and values for paths that were never registered as fields are kept.
Both accept { shouldValidate, shouldDirty, shouldTouch }.
setValue swaps the values but leaves defaultValues untouched, so the form
goes dirty — it is not a reset. Use form.reset(values?) when you want the
new values to become the baseline that isDirty compares against.
Note: this differs from react-hook-form, where
setValueis the single-field writer. Here that issetFieldValue.
reset(values?, options?)
Restores the model to defaultValues (or to values, which then becomes
the new default). Field arrays are realigned: items added via append that the
defaults don't contain are dropped, and an array with no entry in the defaults
resets to []. With no defaultValues at all, the model resets to an empty
object.
Options: keepValues, keepDirty, keepErrors, keepTouched,
keepIsSubmitted, keepSubmitCount.
keepDirty preserves the user's in-progress edits through the reset: every
field that is dirty (differs from the old defaults) keeps its current value,
while clean fields adopt the new ones — the classic "background refetch must
not clobber unsaved edits" case. 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.
form.reset(); // back to the original defaults
form.reset(serverData); // adopt server data as the new baseline
form.reset(serverData, { keepDirty: true }); // refetch without losing edits
form.reset(undefined, { keepValues: true }); // clear errors/touched, keep valuesformState
Every property is a signal — drop it straight into JSX:
<button disabled={form.formState.isSubmitting}>Save</button>;
{
form.formState.isDirty.value && <span>Unsaved changes</span>;
}errors, isDirty, isValid, isValidating, isSubmitting, isSubmitted,
isSubmitSuccessful, submitCount, dirtyFields, touchedFields,
defaultValues, shared.
shared is a writable scratch signal (Signal<{ [key: string]: any }>)
the library never touches: use it to pass ad-hoc state between distant fields
or components that already hold the form control, without wiring up your own
context.
form.formState.shared.value = { activeSection: "billing" };Showing errors
getFieldState(name) returns reactive signals per field: error, isDirty,
isTouched, isValidating, invalid.
import { computed } from "@preact/signals";
const nameError = computed(
() => form.getFieldState("name").error.value?.message
);
// Rendered as a live text node — no re-render:
<span class="error">{nameError}</span>;Validation
Built-in rules
form.register("email", {
required: "Email is required",
pattern: { value: /^[^@]+@[^@]+$/, message: "Invalid email" },
minLength: { value: 5, message: "Too short" },
});required, min, max, minLength, maxLength, pattern, validate,
deps, disabled, plus valueAsNumber / valueAsDate / setValueAs.
Custom validators
validate takes a function receiving the field value and the whole
model — return true/undefined when valid, or a message string:
form.register("confirm", {
validate: (value, values) =>
value === values.password || "Passwords do not match",
});Async validators are awaited (validate: async (v) => …), and out-of-order
results are race-safe: when a newer validation starts while an older one is
still in flight, the stale run's result is discarded and its AbortSignal
fires — pass it to fetch so the server request is actually cancelled, not
just ignored:
form.register("username", {
validate: async (value, _values, signal) => {
const res = await fetch(`/api/check?u=${value}`, { signal });
const { taken } = await res.json();
return taken ? "Already taken" : true;
},
});The signal also aborts when the field is reset, cleared or unregistered. A
validator that rejects after its signal aborted (the normal fetch behaviour)
ends quietly. The same guard applies to async resolvers — a custom resolver
receives the signal as its second argument. To run several named
checks, pass a record — the failing key becomes error.type, and with
criteriaMode: "all" every failure is collected into error.types:
form.register("username", {
validate: {
noSpaces: (v) => !v.includes(" ") || "No spaces allowed",
lowercase: (v) => v === v.toLowerCase() || "Must be lowercase",
},
});Cross-field checks need deps — "when this field changes, re-validate
those":
form.register("password", { deps: ["confirm", "passwordHint"] });Note: configuring a
resolverdisables built-in rules (includingvalidate) entirely — the schema becomes the single source of truth.
Schema resolvers
Resolvers ship as separate entry points, so the schema library never lands in your main bundle:
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(),
age: z.number().min(18),
});
const form = useForm({ resolver: zodResolver(schema) });Also available: …/resolvers/valibot and …/resolvers/yup.
Declarative components
<Form>
Optional <form> wrapper: wires handleSubmit and shares the control through
context, so descendants don't need prop-drilling:
<Form control={form.control} onSubmit={(values) => save(values)}>
<Field name="email" as="input" type="email" />
<button>Save</button>
</Form><Field>
Render-prop or auto-binding, using the control prop or a surrounding
<Form> / <FormProvider>:
{
/* render-prop — full control over the markup */
}
<Field name="email" rules={{ required: true }}>
{({ field, fieldState }) => <input {...field} type="email" />}
</Field>;
{
/* auto-binding — `as` picks the element, extra props pass through */
}
<Field name="plan" as="select">
<option value="free">Free</option>
<option value="pro">Pro</option>
</Field>;<Controller>
For third-party controlled components that can't bind a signal directly (this one does re-render locally on change):
<Controller
control={form.control}
name="color"
render={({ field }) => (
<ColorPicker value={field.value} onChange={field.onChange} />
)}
/><FormProvider> / useFormContext
<FormProvider control={form.control}>
<DeeplyNestedFields />
</FormProvider>;
function DeeplyNestedFields() {
const form = useFormContext<Values>();
return <input {...form.register("email")} />;
}Dynamic lists — useFieldArray
fields is a ReadonlySignal and the hook's return object is stable (created
once). Iterate it with the built-in <For> from @preact/signals/utils — then
even adding/removing rows never re-renders the host component:
import { For } from "@preact/signals/utils";
const { fields, append, remove, move } = useFieldArray({
control: form.control,
name: "items",
});
return (
<>
<For each={fields} getKey={(f) => f.id}>
{(item, i) => (
<div>
<input {...form.register(`items.${i}.name`)} />
<button type="button" onClick={() => remove(i)}>
×
</button>
</div>
)}
</For>
<button type="button" onClick={() => append({ name: "" })}>
Add
</button>
</>
);Prefer plain JSX? Read the signal with fields.value.map(...) — that subscribes
the component, so it re-renders on structural changes only (never on value edits).
Methods: append, prepend, insert, remove, swap, move, replace,
update, clear. fields recomputes only on structural changes; individual
field edits always flow through signals without a re-render.
clear() empties the array outright, dropping the default items along with
anything append added. To go back to the defaults instead, call
form.reset() — see [reset](#resetvalues-options).
Note: item
ids are regenerated byreset(its child field nodes are re-created), so don't persist them outside the form.
Other hooks
useWatch({ control, name? })— aReadonlySignalof one field or the whole model; identical tocontrol.watch(name?), handy when only the control is in scope.useFormState({ control })— the reactiveformStateobject.useController({ control, name, rules?, defaultValue? })— the imperative core of<Controller>, for building custom bound components.useFormContext<Values>()— the nearest control provided by<Form>or<FormProvider>.
Migrating from react-hook-form
| 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 |
| <Controller> for everything | register for native inputs, <Controller> for the rest |
| register("x") returns { onChange, ref, … } | also returns a bindable value signal |
| setValue("x", v) → writes one field | setFieldValue("x", v); setValue(values) replaces the whole model |
| getValues() / watch() | also form.values — the whole model as one signal |
The mental shift: read .value (or render the signal directly) instead of
expecting a re-render.
API reference
Full TSDoc ships with the package (hover in your editor). Public surface:
- Hooks:
useForm,useWatch,useFormState,useController,useFieldArray,useFormContext - Components:
Form,Field,Controller,FormProvider - Core:
createFormControl(framework-agnostic controller factory),FormControlContext - Resolvers:
zodResolver,valibotResolver,yupResolver - Types:
FieldValues,FieldPath,FieldPathValue,RegisterOptions,RegisterReturn,FieldError,FieldErrors,FormState,FieldState,Resolver,SetValueOptions,ResetOptions,UseFormOptions, …
License
MIT © Dmytro Mykhailiuk
