Wit Form

Core concepts

Six ideas explain almost everything Wit Form does. Read this page once and the API pages will make sense quickly.

1. Every field is its own piece of state

In many form libraries the whole form lives in one big object. When any value changes, every component that reads the form renders again.

Wit Form stores each field in its own Jotai atom, a small independent piece of state. A component that calls useField({ name: 'email' }) subscribes to the email atom only. Typing in the email input re-renders that input and nothing else.

This is why large forms, long tables and forms with hundreds of rows stay fast without React.memo or other tuning. The Render Performance example shows it with render counters.

The same applies when you read values. Hooks like useFieldWatch subscribe only to the fields you ask for. See Watching Values.

2. The FormProvider holds one form

FormProvider creates the storage for one form. All Wit Form hooks find their form through the nearest FormProvider above them, so:

  • The component that calls useForm must be inside the FormProvider, not the one that renders it.
  • Each FormProvider is isolated. You can render several forms on one page and they never mix values.
  • Every component that needs the form must be a child of the FormProvider. For the rare case where it can't be, see FormProvider.
// ✅ useForm runs inside the provider
function CheckoutForm() {
  const { handleSubmit } = useForm({ onSubmit });
  return <form onSubmit={handleSubmit}>...</form>;
}

export default withFormProvider(CheckoutForm);

3. Field names are paths

A field's name says where its value goes in the form values. Dots create nested objects:

Field nameSubmitted values
email{ email: '...' }
address.city{ address: { city: '...' } }
settings.theme.color{ settings: { theme: { color: '...' } } }

Initial values use the same shape. A field named address.city reads its starting value from initialValues.address.city.

Lists of rows are handled by field arrays. A field inside a row keeps its plain name (like qty) and says which row it belongs to with ancestors.

4. Each field has a value and an extra info

Every field stores two things:

  • The value: what you send to your API, like a user id, a date string or a number.
  • The extra info (optional): data that belongs with the value but isn't part of it, like the label of the selected option, a user's avatar, or a File object and its preview URL.
// Store the id as the value and the label alongside it
setFieldValue(option.id, { label: option.label });

onSubmit(values, extraInfos) receives both. extraInfos has the same shape as values, so the extra info for assigneeId is at extraInfos.assigneeId.

setFieldValue(value) without a second argument clears the field's extra info. Pass it again when the value changes.

See it in the Extra Info & Files example.

5. Validation runs all the time, errors show when it's polite

A field's validator runs on every change, so it always knows whether the field is valid. The error is only shown once the field is touched:

  • the user leaves the field (its onBlur runs), or
  • the form is submitted, or
  • you validate it yourself with validateFields.

Before that, error from useField is undefined. A user typing their email for the first time doesn't see "Invalid email" after the first letter. Once the field is touched, the error updates live and disappears as soon as the value is fixed. To show errors earlier or later, set useForm's mode.

There are three levels of validation, all covered in Validation. Each one takes a plain function, which can be async, or a schema from any Standard Schema library, like Zod or Valibot:

LevelWhereGood for
FielduseField({ validate, schema })"Required", formats, ranges, server checks
Field arrayuseFieldArray({ validate, schema })"Add at least one row", duplicates
FormuseForm({ validate, schema })Rules across several fields, like date range

A form-level schema can describe the whole form at once. Its errors appear on the matching fields.

6. Fields come and go

Forms often show fields conditionally: a "Company name" input that appears only when "This is a business purchase" is checked.

When a field component unmounts, Wit Form discards whatever the user typed into it. The submitted values then contain:

  • nothing for that field, if it had no initial value, or
  • its initial value, if it had one.

Hidden fields can't leak stale input into onSubmit. The Conditional Fields example shows it.

Sometimes you want the opposite. In a multi-step form, the fields from step 1 unmount when the user moves to step 2, but you still need their values. Pass skipUnregister: true to useForm (for the whole form), or to a single useField or useFieldArray, to keep values when fields unmount. See the Multi-step Wizard example.

To leave initial values of unrendered fields out of the submitted values, pass skipUnusedInitialValues: true to useForm.

The life of a form

Putting it together, here is what happens from mount to submit:

  1. Mount. useForm stores initialValues. Each field reads its starting value from them, or uses its defaultValue.
  2. Editing. Each change updates one field's atom and runs its validator. Components watching that field re-render.
  3. Leaving a field. onBlur marks the field as touched, so its error, if any, appears.
  4. Submit. handleSubmit validates every mounted field, every field array, the form schema and the form-level validate, waiting for any async validators. Every failing field becomes touched.
    • If anything fails, onError is called with the errors and onSubmit is not. The first invalid field is focused, if its ref is attached.
    • Otherwise onSubmit(values, extraInfos) is called. While a returned promise is pending, formState.isSubmitting is true.
  5. After a successful submit. The submitted values become the new initial values, so the form counts as unchanged (not dirty) again. Read more in Loading and Saving Data.

On this page