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
formStateReactive state object (see below)

formState

The formState object exposes reactive getters and methods:

Property Type Description
valuesobjectAll current field values
errorsobjectAll current errors keyed by field name
touchedobjectFields the user has interacted with
isDirty()booleanWhether any field has been modified
isValidcomputedTrue when there are zero errors
isValidating()booleanTrue while async validation is running
isSubmitting()booleanTrue during async submit
isSubmitted()booleanTrue after first submit attempt
submitCount()numberHow many times the form was submitted
dirtyFieldscomputedObject 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
requiredrequired(message?)Fails on empty, null, or undefined
minLengthminLength(n, message?)String must be at least n characters
maxLengthmaxLength(n, message?)String must be at most n characters
minmin(n, message?)Number must be at least n
maxmax(n, message?)Number must be at most n
patternpattern(regex, message?)String must match the regex
emailemail(message?)Must be a valid email address
urlurl(message?)Must be a valid URL
matchmatch(field, message?)Must equal the value of another field
customcustom(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
nameThe 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.

Live Demo — Form Validation