npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

preact-signal-hook-forms

Performant, fully-typed forms for Preact, built entirely on @preact/signals. A functional analogue of react-hook-formsignal-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-forms

Requires 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

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 → a ReadonlySignal<Values> holding the whole model. Render it directly to track the form without a re-render.
  • form.watch(name?) → a ReadonlySignal of one field (or the whole model when called with no argument — the same signal as form.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 read

Writing values: setFieldValue, setValue

  • form.setFieldValue(name, value, options?) → write one field.
  • form.setValue(values, options?) → replace the whole model. Paths missing from values become undefined, 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 setValue is the single-field writer. Here that is setFieldValue.

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 values

formState

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 resolver disables built-in rules (including validate) 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 by reset (its child field nodes are re-created), so don't persist them outside the form.

Other hooks

  • useWatch({ control, name? }) — a ReadonlySignal of one field or the whole model; identical to control.watch(name?), handy when only the control is in scope.
  • useFormState({ control }) — the reactive formState object.
  • 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.isDirtysignal (.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