Overview
Complete, working forms. Each example has a live preview and its full source.
Every example below is a real form you can type into. Open the Code tab on any example to read the whole file. The panel next to most examples shows the form's live values, whether it's dirty, and the last submitted values.
Getting Started
Quick Start
Fill in the form and submit. Leave a required field empty to see its error after you blur it or submit.
Field Component
The same form features without writing a hook per input. Submit with the email empty to see the built-in required check.
Validation
Field Rules
Errors show after you leave a field, or for every field when you submit. Validation itself runs on every change, so an error clears as soon as you fix it.
Cross-field Rules
The new password can't match the current one, and the confirmation must match the new password. Change the new password after confirming it and the confirmation re-validates.
Dynamic Rules
The amount starts above your balance, so submitting fails. Add funds and submit again: the same amount is now checked against the new balance.
Async Rules
Check a username against the server as you type, debounced, and show errors that only the server can find after a submit.
Schema Rules
One Zod schema validates the whole form. Errors land on the matching fields, including fields in list rows.
Form-level Rules
The initial values break several booking rules. Submit to see form-level errors next to field errors, then fix them.
Field Arrays
Invoice
Add, duplicate and remove line items. Row amounts and the total update as you type, and each one re-renders only when the values it watches change.
Nested Arrays
A field array inside a field array. Each section has its own list of lessons and its own running duration.
Watching Values
Conditional Fields
Change the answers and watch fields appear and disappear. When a field unmounts its value is removed, so hidden fields never leak into the submitted values.
Live Preview
The preview and the character counter watch only the fields they show. The yellow badges count renders: the form itself never re-renders as you type.
Patterns
Multi-step Wizard
Each step is validated before you can move on. Go back and forth: values from hidden steps are kept, and the review step reads them all.
Edit a Record
Change something and the Save button turns on. Discard puts the loaded values back. Turn on “Fail the next save” to see a failed save keep your edits.
Imperative Updates
Fill fields from code: load a saved address, look up a city from a ZIP code, copy one address into another, or clear one.
Extra Info & Files
Each field can carry an extraInfo next to its value. Pick an assignee and upload an image, then compare the values and extra infos on the right.
Advanced
Render Performance
Edit any cell and watch the yellow render counters: only that cell and its column total re-render, however many rows there are.
Outside the Provider
The order summary lives outside the form's FormProvider, in a different part of the page. It reads and updates the form through its formId.
Shared components
Wit Form ships no UI. The examples share a few field components built on useField, the way you'd write them once for your own app:
'use client';
/**
* Reusable field components built on `useField`.
*
* wit-form doesn't ship any UI. You write a small set of components like these once, styled
* for your app, and every form reuses them. Each one subscribes to a single field, so typing
* in it re-renders only that component.
*/
import { useId, useMemo, type ReactNode } from 'react';
import { useField, type IAncestorInput, type StandardSchemaV1 } from 'wit-form';
/** Returns an error message (or a promise of one), or null when the value is valid */
type Validator<T> = (
value: T | undefined,
other?: { values: any; extraInfos: any }
) => string | null | undefined | Promise<string | null | undefined>;
type DepFields = (string | { name: string; ancestors?: IAncestorInput[] })[];
interface BaseFieldProps<T> {
/** Field path, e.g. `email` or `address.city` */
name: string;
label?: string;
hint?: ReactNode;
required?: boolean;
validate?: Validator<T>;
/** A Standard Schema (Zod, Valibot, ArkType, ...) for this field's value */
schema?: StandardSchemaV1;
/** Wait for typing to pause (in milliseconds) before validating, e.g. for server checks */
debounceValidation?: number;
/** Fields whose values are passed to `validate` as `other.values` */
depFields?: DepFields;
/** Only needed for fields inside a field array row */
ancestors?: IAncestorInput[];
defaultValue?: T;
disabled?: boolean;
/** Hide the label visually, e.g. in table cells. It is still read by screen readers. */
hideLabel?: boolean;
className?: string;
}
const isEmpty = (value: unknown) =>
value === undefined || value === null || value === '' || value === false;
/** Combines the `required` check with a custom validator */
function useValidator<T>(
required: boolean | undefined,
validate: Validator<T> | undefined
): Validator<T> {
return useMemo(
() => (value, other) => {
if (required && isEmpty(value)) return 'Required';
return validate?.(value, other) ?? null;
},
[required, validate]
);
}
const inputClass =
'block w-full rounded-md border-0 bg-white py-1.5 px-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-inset placeholder:text-slate-400 focus:ring-2 focus:ring-inset disabled:bg-slate-50 disabled:text-slate-500';
function inputRing(error: unknown) {
return error
? 'ring-red-300 focus:ring-red-500'
: 'ring-slate-300 focus:ring-emerald-600';
}
function FieldShell(props: {
id: string;
label?: string;
hideLabel?: boolean;
required?: boolean;
hint?: ReactNode;
error?: string | null;
/** An async validator is running */
validating?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<div className={props.className}>
{props.label && (
<label
htmlFor={props.id}
className={
props.hideLabel
? 'sr-only'
: 'mb-1 block text-sm font-medium text-slate-700'
}
>
{props.label}
{props.required && <span className="text-red-500"> *</span>}
</label>
)}
{props.children}
{props.validating ? (
<p className="mt-1 text-xs text-slate-500" aria-live="polite">
Checking…
</p>
) : props.error ? (
<p id={`${props.id}-error`} className="mt-1 text-xs text-red-600">
{props.error}
</p>
) : (
props.hint && (
<p id={`${props.id}-hint`} className="mt-1 text-xs text-slate-500">
{props.hint}
</p>
)
)}
</div>
);
}
function describedBy(id: string, error: unknown, hint: unknown) {
if (error) return `${id}-error`;
if (hint) return `${id}-hint`;
return undefined;
}
export function TextField(
props: BaseFieldProps<string> & {
type?: 'text' | 'email' | 'password' | 'url' | 'tel' | 'date';
placeholder?: string;
autoComplete?: string;
/** Called after the value changes, with the new value */
onValueChange?: (value: string) => void;
onBlur?: () => void;
}
) {
const id = useId();
const { fieldValue, setFieldValue, onBlur, error, isValidating, ref } =
useField<string>({
name: props.name,
ancestors: props.ancestors,
defaultValue: props.defaultValue,
depFields: props.depFields,
validate: useValidator(props.required, props.validate),
schema: props.schema,
debounceValidation: props.debounceValidation,
});
return (
<FieldShell id={id} error={error} validating={isValidating} {...props}>
<input
id={id}
ref={ref}
type={props.type ?? 'text'}
className={`${inputClass} ${inputRing(error)}`}
value={fieldValue ?? ''}
placeholder={props.placeholder}
autoComplete={props.autoComplete}
disabled={props.disabled}
aria-invalid={!!error}
aria-describedby={describedBy(id, error, props.hint)}
onChange={(e) => {
setFieldValue(e.target.value);
props.onValueChange?.(e.target.value);
}}
onBlur={() => {
onBlur();
props.onBlur?.();
}}
/>
</FieldShell>
);
}
export function TextAreaField(
props: BaseFieldProps<string> & { placeholder?: string; rows?: number }
) {
const id = useId();
const { fieldValue, setFieldValue, onBlur, error, isValidating, ref } =
useField<string>({
name: props.name,
ancestors: props.ancestors,
defaultValue: props.defaultValue,
depFields: props.depFields,
validate: useValidator(props.required, props.validate),
schema: props.schema,
debounceValidation: props.debounceValidation,
});
return (
<FieldShell id={id} error={error} validating={isValidating} {...props}>
<textarea
id={id}
ref={ref}
rows={props.rows ?? 3}
className={`${inputClass} ${inputRing(error)}`}
value={fieldValue ?? ''}
placeholder={props.placeholder}
disabled={props.disabled}
aria-invalid={!!error}
aria-describedby={describedBy(id, error, props.hint)}
onChange={(e) => setFieldValue(e.target.value)}
onBlur={onBlur}
/>
</FieldShell>
);
}
/** Stores a `number`, or `undefined` while the input is empty */
export function NumberField(
props: BaseFieldProps<number> & {
min?: number;
max?: number;
step?: number;
placeholder?: string;
prefix?: string;
}
) {
const id = useId();
const { fieldValue, setFieldValue, onBlur, error, isValidating, ref } =
useField<number | undefined>({
name: props.name,
ancestors: props.ancestors,
defaultValue: props.defaultValue,
depFields: props.depFields,
validate: useValidator(props.required, props.validate),
schema: props.schema,
debounceValidation: props.debounceValidation,
});
return (
<FieldShell id={id} error={error} validating={isValidating} {...props}>
<div className="relative">
{props.prefix && (
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 text-sm text-slate-500">
{props.prefix}
</span>
)}
<input
id={id}
ref={ref}
type="number"
inputMode="decimal"
className={`${inputClass} ${inputRing(error)} ${
props.prefix ? 'pl-6' : ''
}`}
value={fieldValue ?? ''}
min={props.min}
max={props.max}
step={props.step}
placeholder={props.placeholder}
disabled={props.disabled}
aria-invalid={!!error}
aria-describedby={describedBy(id, error, props.hint)}
onChange={(e) =>
setFieldValue(
e.target.value === '' ? undefined : e.target.valueAsNumber
)
}
onBlur={onBlur}
/>
</div>
</FieldShell>
);
}
export interface Option {
label: string;
value: string;
}
/**
* Stores the option's `value`. The option's `label` is stored as the field's `extraInfo`,
* so it's available in `onSubmit(values, extraInfos)` without looking it up again.
*/
export function SelectField(
props: BaseFieldProps<string> & {
options: Option[];
placeholder?: string;
}
) {
const id = useId();
const { fieldValue, setFieldValue, onBlur, error, isValidating, ref } =
useField<string, { label: string }>({
name: props.name,
ancestors: props.ancestors,
defaultValue: props.defaultValue,
depFields: props.depFields,
validate: useValidator(props.required, props.validate),
schema: props.schema,
debounceValidation: props.debounceValidation,
});
return (
<FieldShell id={id} error={error} validating={isValidating} {...props}>
<select
id={id}
ref={ref}
className={`${inputClass} ${inputRing(error)} pr-8`}
value={fieldValue ?? ''}
disabled={props.disabled}
aria-invalid={!!error}
aria-describedby={describedBy(id, error, props.hint)}
onChange={(e) => {
const option = props.options.find((o) => o.value === e.target.value);
setFieldValue(
e.target.value,
option ? { label: option.label } : undefined
);
}}
onBlur={onBlur}
>
<option value="">{props.placeholder ?? 'Select…'}</option>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FieldShell>
);
}
export function CheckboxField(
props: Omit<BaseFieldProps<boolean>, 'hideLabel'> & { label: ReactNode }
) {
const id = useId();
const { fieldValue, setFieldValue, onBlur, error, isValidating, ref } =
useField<boolean>({
name: props.name,
ancestors: props.ancestors,
defaultValue: props.defaultValue,
depFields: props.depFields,
validate: useValidator(props.required, props.validate),
schema: props.schema,
debounceValidation: props.debounceValidation,
});
return (
<div className={props.className}>
<div className="flex items-start gap-2">
<input
id={id}
ref={ref}
type="checkbox"
className="mt-0.5 h-4 w-4 accent-emerald-600"
checked={!!fieldValue}
disabled={props.disabled}
aria-invalid={!!error}
aria-describedby={describedBy(id, error, props.hint)}
onChange={(e) => setFieldValue(e.target.checked)}
onBlur={onBlur}
/>
<label htmlFor={id} className="text-sm text-slate-700">
{props.label}
</label>
</div>
{error ? (
<p id={`${id}-error`} className="mt-1 text-xs text-red-600">
{error}
</p>
) : (
props.hint && (
<p id={`${id}-hint`} className="mt-1 text-xs text-slate-500">
{props.hint}
</p>
)
)}
</div>
);
}
They also use small presentational helpers (buttons, cards and the form state panel) from examples/components/ui.tsx.