Forms
Build validated, reactive forms with minimal boilerplate.
useForm
useForm provides complete form state management, field registration, validation, and submission handling in a single hook. It uses per-field signals internally so that updating one input never causes unrelated fields to re-render.
import { useForm } from 'what-framework';
const { register, handleSubmit, formState } = useForm({
defaultValues: { email: '', password: '' },
mode: 'onSubmit', // 'onSubmit' | 'onChange' | 'onBlur'
reValidateMode: 'onChange',
resolver: undefined, // optional schema resolver
});
Return Value
useForm returns an object with the following properties:
| Property | Description |
|---|---|
register(name, opts?) | Connect a field to the form |
handleSubmit(onValid, onInvalid?) | Returns a submit handler that validates first |
setValue(name, value, opts?) | Programmatically set a field value |
getValue(name) | Read a field's current value |
setError(name, error) | Manually set a field error |
clearError(name) | Clear a single field's error |
clearErrors() | Clear all errors at once |
reset(newValues?) | Reset the form to defaults or new values |
watch(name?) | Returns a computed signal for one field or all values |
validate(name?) | Trigger validation for one field or the entire form |
formState | Reactive state object (see below) |
formState
The formState object exposes reactive getters and methods:
| Property | Type | Description |
|---|---|---|
values | object | All current field values |
errors | object | All current errors keyed by field name |
touched | object | Fields the user has interacted with |
isDirty() | boolean | Whether any field has been modified |
isValid | computed | True when there are zero errors |
isValidating() | boolean | True while async validation is running |
isSubmitting() | boolean | True during async submit |
isSubmitted() | boolean | True after first submit attempt |
submitCount() | number | How many times the form was submitted |
dirtyFields | computed | Object mapping dirty field names to true |
Complete Example
A registration form with email, password, and password confirmation:
import { useForm, simpleResolver, rules } from 'what-framework';
const { register, handleSubmit, formState } = useForm({
defaultValues: { email: '', password: '', confirmPassword: '' },
mode: 'onBlur',
resolver: simpleResolver({
email: [rules.required(), rules.email()],
password: [rules.required(), rules.minLength(8)],
confirmPassword: [rules.required(), rules.match('password', 'Passwords must match')],
}),
});
function Register() {
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('email')} type="email" placeholder="Email" />
<input {...register('password')} type="password" placeholder="Password" />
<input {...register('confirmPassword')} type="password" placeholder="Confirm" />
<button type="submit">Sign Up</button>
</form>
);
}
register()
The register function connects a form field to the reactive state. Call it with a field name and spread the returned props onto your input element.
const emailProps = register('email');
// Returns: { name, value, onInput, onBlur, onFocus }
// Spread onto any input element
<input {...register('email')} type="email" />
<textarea {...register('bio')} />
<select {...register('role')}>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
Each registered field gets its own internal signal. This means typing into the email field only updates components that read the email value, not every field in the form.
Checkbox handling
register automatically detects type="checkbox" inputs and reads e.target.checked instead of e.target.value, so boolean fields work out of the box.
Validation
What provides a set of built-in validation rules through the rules export. Combine them with simpleResolver to define constraints per field.
Built-in Rules
| Rule | Signature | Description |
|---|---|---|
required | required(message?) | Fails on empty, null, or undefined |
minLength | minLength(n, message?) | String must be at least n characters |
maxLength | maxLength(n, message?) | String must be at most n characters |
min | min(n, message?) | Number must be at least n |
max | max(n, message?) | Number must be at most n |
pattern | pattern(regex, message?) | String must match the regex |
email | email(message?) | Must be a valid email address |
url | url(message?) | Must be a valid URL |
match | match(field, message?) | Must equal the value of another field |
custom | custom(fn) | Pass any (value, allValues) => string | undefined |
Example with Multiple Rules
import { simpleResolver, rules } from 'what-framework';
const resolver = simpleResolver({
username: [
rules.required('Username is required'),
rules.minLength(3, 'At least 3 characters'),
rules.maxLength(20),
rules.pattern(/^[a-zA-Z0-9_]+$/, 'Letters, numbers, and underscores only'),
],
age: [
rules.required(),
rules.min(13, 'Must be at least 13'),
rules.max(120),
],
website: [
rules.url('Enter a valid URL'),
],
});
Displaying Errors
Use the ErrorMessage component to display errors inline. It reads the formState.errors getter object and renders only when the named field has an error.
import { ErrorMessage } from 'what-framework';
<form onSubmit={handleSubmit(onValid)}>
<input {...register('email')} type="email" />
<ErrorMessage name="email" formState={formState} />
// Custom render for full control
<ErrorMessage
name="email"
formState={formState}
render={({ message }) => <span class="field-error">{message}</span>}
/>
</form>
Schema Validation
For complex forms, use zodResolver or yupResolver to validate against a schema. This keeps validation logic declarative and co-located with your type definitions.
Zod Example
import { z } from 'zod';
import { useForm, zodResolver } from 'what-framework';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
age: z.number().min(13).max(120),
});
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', age: 0 },
});
Yup Example
import * as yup from 'yup';
import { useForm, yupResolver } from 'what-framework';
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
const { register, handleSubmit } = useForm({
resolver: yupResolver(schema),
});
Custom resolvers
A resolver is any async function that accepts values and returns { values, errors }. Use simpleResolver(rules) for lightweight validation without pulling in Zod or Yup.
useField
useField gives you standalone control over a single field without a parent useForm. This is useful for isolated inputs like search bars, inline edits, or fields managed outside a form context.
import { useField } from 'what-framework';
const username = useField('username', {
defaultValue: '',
validate: (v) => v.length < 3 ? 'Too short' : undefined,
});
Return Value
| Property | Description |
|---|---|
name | The field name string |
value() | Current value (reactive) |
error() | Current error message or null |
isTouched() | Whether the field has been blurred |
isDirty() | Whether the value differs from default |
setValue(v) | Set the value programmatically |
validate() | Run the validate function manually |
reset() | Reset to default value and clear state |
inputProps() | Returns { name, value, onInput, onBlur } to spread |
function SearchBar() {
const search = useField('search');
return (
<div>
<input {...search.inputProps()} placeholder="Search..." />
{() => search.error() &&
<span class="error">{search.error()}</span>
}
</div>
);
}
useField vs register
Use register when you already have a useForm instance and want coordinated validation and submission. Use useField when a field is independent or you need fine-grained control over its lifecycle.
Form Components
What exports pre-built form components that wire up register, ARIA attributes, and error state automatically. Each component accepts a register prop (the register function from useForm) plus any standard HTML attributes.
| Component | Renders | Notes |
|---|---|---|
Input | <input> | Sets aria-invalid when error is present |
Textarea | <textarea> | Sets aria-invalid when error is present |
Select | <select> | Pass <option> elements as children |
Checkbox | <input type="checkbox"> | Reads checked from registered value |
Radio | <input type="radio"> | Pass a value prop; checked when it matches |
ErrorMessage | <span role="alert"> | Shows error text or uses custom render prop |
Building a Form with Components
import {
useForm, Input, Textarea, Select,
Checkbox, ErrorMessage, simpleResolver, rules
} from 'what-framework';
function ContactForm() {
const { register, handleSubmit, formState } = useForm({
defaultValues: { name: '', message: '', topic: 'general', subscribe: false },
resolver: simpleResolver({
name: [rules.required()],
message: [rules.required(), rules.minLength(10)],
}),
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<Input name="name" register={register} placeholder="Your name" />
<ErrorMessage name="name" formState={formState} />
<Select name="topic" register={register}>
<option value="general">General</option>
<option value="support">Support</option>
<option value="feedback">Feedback</option>
</Select>
<Textarea name="message" register={register} rows={4} />
<ErrorMessage name="message" formState={formState} />
<label>
<Checkbox name="subscribe" register={register} />
Subscribe to updates
</label>
<button type="submit">Send</button>
</form>
);
}
Always pass register as a prop
The pre-built components call register(props.name) internally. Make sure you pass the register function from your useForm instance, not the result of calling it.