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.
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.
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.