Skip to content

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.

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.

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