Skip to content

Forms in Whisq work with standard DOM events and reactive signals. There’s no special form library — just signal() for state, computed() for validation, element functions for the UI, and bind() to wire an input to a signal in one line.

See all @whisq/* entry points for the complete sub-path list (/forms for bindPath, plus the other helpers used elsewhere in the docs).

Spread bind(signal) into an input to get two-way binding:

import { signal, bind, div, input, p } from "@whisq/core";
const name = signal("");
div(
input({ placeholder: "Your name", ...bind(name) }),
p(() => `Hello, ${name.value || "stranger"}!`),
);

That’s it. The signal holds the latest value at all times; name.value always reflects what’s typed.

bind() covers every common input type. Pass the right signal type and (for non-text types) an as: option:

import { signal, bind, div, input, textarea, select, option, label } from "@whisq/core";
const email = signal("");
const age = signal(0);
const bio = signal("");
const role = signal("dev");
const tier = signal<"free" | "pro">("free");
const agreed = signal(false);
div(
label("Email"),
input({ type: "email", ...bind(email) }),
label("Age"),
input({ type: "number", ...bind(age, { as: "number" }) }),
label("Bio"),
textarea({ ...bind(bio) }),
label("Role"),
select({ ...bind(role) },
option({ value: "dev" }, "Developer"),
option({ value: "design" }, "Designer"),
option({ value: "pm" }, "Product Manager"),
),
label(
input({ type: "radio", name: "tier", ...bind(tier, { as: "radio", value: "free" }) }),
" Free",
),
label(
input({ type: "radio", name: "tier", ...bind(tier, { as: "radio", value: "pro" }) }),
" Pro",
),
label(
input({ type: "checkbox", ...bind(agreed, { as: "checkbox" }) }),
" I agree to the terms",
),
);

Handle submission with onsubmit and preventDefault:

import { signal, bind, component, form, input, button, label, p } from "@whisq/core";
const ContactForm = component(() => {
const email = signal("");
const message = signal("");
const submitted = signal(false);
const handleSubmit = (e: Event) => {
e.preventDefault();
console.log({ email: email.value, message: message.value });
submitted.value = true;
};
return form({ onsubmit: handleSubmit },
label("Email"),
input({ type: "email", ...bind(email) }),
label("Message"),
input({ ...bind(message) }),
button("Send"),
p(() => (submitted.value ? "Sent!" : "")),
);
});

Derive validation state with computed() over the bound signals:

import { signal, computed, bind, component, form, input, button, label, p, when } from "@whisq/core";
const SignupForm = component(() => {
const email = signal("");
const password = signal("");
const emailValid = computed(() => email.value.includes("@"));
const passwordValid = computed(() => password.value.length >= 8);
const formValid = computed(() => emailValid.value && passwordValid.value);
return form({ onsubmit: (e) => { e.preventDefault(); } },
label("Email"),
input({ type: "email", ...bind(email) }),
when(() => email.value && !emailValid.value,
() => p({ class: "error" }, "Enter a valid email address"),
),
label("Password"),
input({ type: "password", ...bind(password) }),
when(() => password.value && !passwordValid.value,
() => p({ class: "error" }, "Password must be at least 8 characters"),
),
button({ disabled: () => !formValid.value }, "Sign Up"),
);
});

Validation messages only appear after the user starts typing — email.value && !emailValid.value keeps the error hidden when the field is empty.

Reset all signals to their initial values:

const email = signal("");
const password = signal("");
const reset = () => {
email.value = "";
password.value = "";
};
form({ onsubmit: handleSubmit },
// ... fields using ...bind(email), ...bind(password)
button({ type: "button", onclick: reset }, "Reset"),
button("Submit"),
);

Because bind() reads from the signal reactively, clearing the signal clears the input — no extra wiring needed.

bind() covers “one input, one signal.” For per-row inputs in a list — checkboxes per todo, quantity inputs in a cart, edit fields in a CRUD grid — reach for bindField(source, item, key, options?). It’s the same discriminator surface (text / number / checkbox / radio) but reaches into a field on an item inside a signal-held array.

import { signal, each, ul, li, input, bindField } from "@whisq/core";
interface Todo { id: number; text: string; done: boolean }
const todos = signal<Todo[]>([
{ id: 1, text: "Learn Whisq", done: false },
{ id: 2, text: "Build an app", done: false },
]);
ul(
each(() => todos.value, (todo) =>
li(
input({ type: "checkbox", ...bindField(todos, todo, "done", { as: "checkbox" }) }),
input({ ...bindField(todos, todo, "text") }),
),
{ key: (t) => t.id },
),
)

Three things to notice:

  • todo is the keyed-each() accessor — bindField’s second argument expects exactly that shape.
  • Writes produce immutable array updates — the array is reassigned, not mutated. Downstream computed, effect, and other keyed-each() blocks reconcile correctly.
  • If the item is removed from source between render and the user’s edit, the write logs a console warning and is discarded rather than silently corrupting state. Custom item shapes can override the default t => t.id lookup with { keyBy: (t) => t.uuid }.

See /api/bindfield/ for every variant.

For the third common case — fields nested inside an object held by a single signal (user.profile.email, settings.billing.plan) — reach for bindPath(source, path, options?) from @whisq/core/forms. It lives on a sub-path so apps that don’t need it pay no bundle cost.

import { signal, form, input, label } from "@whisq/core";
import { bindPath } from "@whisq/core/forms";
interface User {
profile: { name: string; email: string; age: number };
prefs: { dark: boolean };
}
const user = signal<User>({
profile: { name: "", email: "", age: 0 },
prefs: { dark: false },
});
form(
label("Name", input({ ...bindPath(user, ["profile", "name"]) })),
label("Email", input({ type: "email", ...bindPath(user, ["profile", "email"]) })),
label("Age", input({ type: "number", ...bindPath(user, ["profile", "age"], { as: "number" }) })),
label("Dark mode", input({ type: "checkbox", ...bindPath(user, ["prefs", "dark"], { as: "checkbox" }) })),
)

Three things to know:

  • Structural sharing on writes. Each write produces a new root and new objects at every level on the path; sibling branches keep their reference identity. Downstream computed / effect that depend on unaffected branches don’t re-run.
  • Object keys only — no array traversal. ["items", 0] would treat 0 as an object key. For per-item fields inside an array, use bindField() at the array level (see the section above) and compose with bindPath for nested-object cells.
  • TypeScript checks paths through depth 4 — autocomplete + misuse detection on path keys against the parent shape. Deeper paths fall through to a loose signature.

See /api/bindpath/ for every variant and the full semantics.

Under the hood, bind(signal) on a text input expands to the same prop pair you’d write by hand:

// What ...bind(name) expands to for a text input, textarea, or select:
input({
value: () => name.value,
oninput: (e) =>
name.value = (
e.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
).value,
})

<select> fires the input event on change too (HTML5 behaviour), which is why the same bind() spread works for selects and textareas without a separate overload.

Reach for the manual form only when you genuinely need something bind() doesn’t cover:

  • Custom event names — e.g. a web component that emits value-changed instead of input.
  • Debounced or throttled input — wrap your own oninput around a debounce helper before writing to the signal.
  • Transforms on write — e.g. trimming whitespace, uppercasing, normalising before assigning.
  • Derived values — when the input and signal shapes differ enough that coercion via { as: "..." } isn’t the right abstraction.

For everything else, use bind().

Docs current to v0.1.0-alpha.9 . All releases →