Forms
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).
Basic Input Binding
Section titled “Basic Input Binding”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.
Input Types
Section titled “Input Types”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", ),);Form Submission
Section titled “Form Submission”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!" : "")), );});Validation
Section titled “Validation”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.
Form Reset
Section titled “Form Reset”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.
Binding fields inside list items
Section titled “Binding fields inside list items”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:
todois 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
sourcebetween 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 defaultt => t.idlookup with{ keyBy: (t) => t.uuid }.
See /api/bindfield/ for every variant.
Binding into nested records
Section titled “Binding into nested records”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/effectthat depend on unaffected branches don’t re-run. - Object keys only — no array traversal.
["items", 0]would treat0as an object key. For per-item fields inside an array, usebindField()at the array level (see the section above) and compose withbindPathfor nested-object cells. - TypeScript checks paths through depth 4 — autocomplete + misuse detection on
pathkeys against the parent shape. Deeper paths fall through to a loose signature.
See /api/bindpath/ for every variant and the full semantics.
Why bind() exists
Section titled “Why bind() exists”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-changedinstead ofinput. - Debounced or throttled input — wrap your own
oninputaround 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().
Next Steps
Section titled “Next Steps”- Signals — Deep dive into reactive state.
- Data Fetching — Submit forms to APIs with
resource(). - State Management — Share form state across components.
- Event types reference —
WhisqEvent,EventHandler, and the dev-mode error classes for typed-handler patterns.
Docs current to v0.1.0-alpha.9 . All releases →