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,     // no resolver means no validation at all
});

All validation lives in resolver. Without one, validate() resolves true immediately and handleSubmit always takes the valid branch, which leaves mode and reValidateMode with nothing to do. Per-field rules in the second argument to register (the React Hook Form shape, register('email', { required: true })) are not supported: register reads only type, value, and ref from its options and ignores the rest.

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?)Set a field value in form state
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 form state 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
error(name)object | nullThe { type, message } error for one field, or null
touchedobjectFields the user has interacted with
isDirty()booleanWhether any field has been modified
isValid()computedTrue 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
dirtyFields()computedObject 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, ref }
// The event key is lowercase 'oninput'. There is no 'onInput'.

// register(name, { type: 'checkbox' }) swaps value/oninput for:
//   { name, checked, onchange, onBlur, onFocus, ref }

// register(name, { type: 'radio', value }) returns:
//   { name, value, checked, onchange, onBlur, onFocus, ref }

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

A spread text, select, or checkbox input is uncontrolled. Its value (or checked) is a getter, and spreading reads it exactly once, when the element is created. Typing flows into form state, but setValue and reset move form state without moving the DOM. When you need the input to follow the state (a reset button, prefilling from a fetch), bind the value as a thunk instead of spreading it:

<input
  name="email"
  value={() => getValue('email')}
  oninput={register('email').oninput}
/>

A registered radio is the exception: its checked comes back as a thunk rather than a getter, so the renderer keeps it live and a spread radio does follow setValue and reset.

Checkbox and radio 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. Radios work differently, because every radio in a group shares one field that holds the selected option's value. Tell the registration which value this radio contributes, register('plan', { type: 'radio', value: 'pro' }), or let Radio do it for you. A bare register('plan') spread onto an <input type="radio"> warns at mount in development, because the registration's own value binding overwrites the input's value and the group is then unable to record a choice.

Radios do not work on 0.13.4

Everything this page says about radios describes the next release. On 0.13.4, the current version on npm, a radio registration treats the field as a plain boolean instead of comparing it against the radio's own value: every radio in a group renders checked at mount, setValue and reset do not move the selection, and picking one writes true into the shared field rather than the value it stands for. Radio fares slightly better and still does not work: it paints the initial selection correctly, then goes nowhere. setValue and reset do not move it, and clicking one leaves the field untouched, because its change handler calls a key register has never defined. Pass it an onChange, onBlur or onFocus of your own and only one of the two handlers runs. Text inputs, selects, textareas and checkboxes are unaffected: they behave exactly as described here on both versions.

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. Skipped unless the value is a number
maxmax(n, message?)Number must be at most n. Skipped unless the value is a number
patternpattern(regex, message?)String must match the regex
emailemail(message?)Must be a valid email address. Also fails on ''
urlurl(message?)Must be a valid URL. Skips an empty value
matchmatch(field, message?)Must equal the value of another field
customcustom(fn)Pass any (value, allValues) => string | undefined

Every rule is a plain function of the value, and every rule except required and match skips a value of the wrong type. Two consequences are worth spelling out. register stores e.target.value, which is a string even for <input type="number">, so min and max never fire on typed input; use them only for values you write yourself with setValue, and reach for custom otherwise. And email tests any string including the empty one, so an untouched optional email field stays invalid and blocks submission (url skips empty, email does not).

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'),
  ],
  // A typed value is a string, so range-check it with custom()
  age: [
    rules.required(),
    rules.custom((v) => (Number(v) >= 13 && Number(v) <= 120
      ? undefined
      : 'Must be between 13 and 120')),
  ],
  website: [
    rules.url('Enter a valid URL'),
  ],
});

Displaying Errors

Errors appear after the component has already run, and a component body runs exactly once. So the error has to be read inside a reactive thunk, {() => ...}: the thunk is the part that re-runs. Read formState.errors there and render the message yourself, or let ErrorMessage render it from inside the same thunk.

import { ErrorMessage } from 'what-framework';

<form onSubmit={handleSubmit(onValid)}>
  <input {...register('email')} type="email" />

  {/* the thunk reads the error, so it re-runs when it changes */}
  {() => formState.errors.email &&
    <span class="field-error">{formState.errors.email.message}</span>}

  {/* or hand it to ErrorMessage, from inside the same kind of thunk */}
  {() => formState.errors.email &&
    <ErrorMessage name="email" formState={formState} />}
</form>

ErrorMessage renders <span class="what-error" role="alert"> with the message, or calls a render={({ message, type }) => ...} prop when you want the markup to be yours. What it does not do is subscribe on its own: dropped straight into the form, it reads the error once during the single run of its body, finds none, and renders nothing forever.

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.

Resolvers fail closed

zodResolver works on both Zod 3 and Zod 4. It reads ZodError.issues and falls back to the errors alias that only Zod 3 carries. If a schema throws something that is not a validation error, at all, both zodResolver and yupResolver rethrow rather than reporting the form as valid, so a broken schema is loud instead of silently disabling validation.

Both of those are the next release. On 0.13.4, the current version on npm, zodResolver reads only the Zod 3 errors alias, so a Zod 4 schema hands back an empty error map, isValid() is true, and handleSubmit takes the valid branch with an invalid form. A schema that throws a TypeError does the same thing. Pin Zod 3 until then, or write the resolver by hand: it is any async (values) => ({ values, errors }).

Zod Example

import { z } from 'zod';  // zod 3 or 4
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 field has been written to since the last reset()
setValue(v)Set the value programmatically
setError(e)Set the error message manually
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 a few small components that call register for you and spread the result. Each accepts a register prop (the register function from useForm) plus any standard HTML attributes.

Component Renders Notes
Input<input>Sets aria-invalid when you pass an error prop
Textarea<textarea>Sets aria-invalid when you pass an error prop
Select<select>Pass <option> elements as children
Checkbox<input type="checkbox">Takes its initial checked from the registered value; toggling writes back
Radio<input type="radio">Needs its own value. Writes that value to the shared field and keeps checked in sync in both directions
ErrorMessage<span role="alert">Shows error text or uses a custom render prop. Place it inside a thunk (see Displaying Errors)

None of the input components consults formState. ErrorMessage is the exception: it reads formState.error(name), so it needs formState passed to it or it renders nothing. aria-invalid comes from an error prop you pass yourself, read once when the element is created. Pass the error value rather than a thunk: a function is always truthy, so it marks the field invalid from first paint.

Because Input, Textarea, Select, and Checkbox spread register() over your props, the inputs they produce are uncontrolled in the same way as a hand-spread register(), and the registration wins every prop it defines, so an onBlur, onFocus, or ref handed to them is dropped. Radio is the exception on both counts: its checked is a live binding, and it composes your onChange, onBlur, onFocus, and ref with the registration's rather than replacing them.

Reach for Radio when you need a radio group. Every radio in the group shares one field, and that field holds the selected option's value, so each one declares the value it contributes:

import { Radio } from 'what-framework';

<label><Radio name="plan" value="free" register={register} /> Free</label>
<label><Radio name="plan" value="pro" register={register} /> Pro</label>
<label><Radio name="plan" value="team" register={register} /> Team</label>

Picking one writes its value into plan, and the binding runs the other way too, so setValue('plan', 'pro'), reset(), and a defaultValues entry all move the selection on screen.

To wire a radio by hand, register it with its own value and keep type="radio" on the element. The type option tells the registration how to behave and does not set the element's type, so an input without the attribute is a text box that no click will ever change:

<input type="radio" {...register('plan', { type: 'radio', value: 'free' })} />
<input type="radio" {...register('plan', { type: 'radio', value: 'pro' })} />

// Client-only alternative: leave the value option off and let each input
// carry its own, the way a plain HTML radio group already works.
<input type="radio" value="sm" {...register('size', { type: 'radio' })} />
<input type="radio" value="lg" {...register('size', { type: 'radio' })} />

Those two forms behave identically in the browser: mount, setValue, reset, and a real click move the selection the same way either way. They are not the same on the server. A declared value gives the registration something to compare the field against, so the selected radio ships already checked, <input type="radio" name="plan" value="pro" checked>. Without one the registration has no value until an element hands it one through the ref, and the server renderer skips ref entirely, so every radio in the group serializes unchecked: a server-rendered page paints an empty radio group and only fills it in at hydration. Declare the value on anything you render on the server.

A radio that cannot say which value it stands for is treated as a mistake rather than given a default, since the DOM reports "on" for all of them and a group of those cannot record a choice. A Radio with no value warns in development and renders an unregistered input, so it never touches form state. A register(name, { type: 'radio' }) on an input carrying no value attribute warns when it changes and leaves the field alone.

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" />
      {() => formState.errors.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} />
      {() => formState.errors.message &&
        <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