The Form component and useFormFields hook provide a type-safe, schema-first way to build forms using Valibot.
Instead of manually managing state, event listeners, and validation logic for every input, you define a Schema that represents your data.
Form component uses this schema to validate data automatically on submission. It uses the native FormData API to collect values, so standard HTML inputs work out of the box.useFormFields hook analyzes your schema to generate the correct HTML attributes (like type="email", required, minLength, accept) for your inputs.Before building the UI, you need to define what your data looks like. We use valibot for this.
valibot.import { object, string, email, minLength, pipe } from 'valibot';
// Define the shape of your form data
const LoginSchema = object({
email: pipe(string(), email()),
password: pipe(string(), minLength(8)),
});
Use the useFormFields hook to automatically generate the HTML attributes for your inputs based on your schema. This ensures your UI stays in sync with your validation rules.
Note on IDs: The hook generates unique IDs for each field (e.g., :r1:-email) using crypto.randomUUID, ensuring accessibility and preventing conflicts even if you render multiple forms on the same page.
import { useFormFields } from '@packages/werkbank/component/form/hooks';
function LoginForm() {
// Generate attributes like { name: "email", type: "email", required: true }
const fields = useFormFields(LoginSchema);
return (
// ...
);
}
Wrap your inputs in the Form component. Pass the schema and an onSubmit handler.
Note: You do not need to pass value or onChange to your inputs. The Form component handles data collection automatically when the user submits.
import { Form } from '@packages/werkbank/component/form';
function LoginForm() {
const fields = useFormFields(LoginSchema);
return (
<Form
schema={LoginSchema}
onSubmit={(data, event) => {
// The event.preventDefault() is called automatically.
// 'data' is fully typed and validated, ready to be sent via fetch/axios.
console.log("Logging in with:", data.email);
// Reset the form after successful submission
event.currentTarget.reset();
}}
className="p-4 border rounded" // Standard HTML attributes are passed through
>
<label htmlFor={fields.email.id}>Email Address</label>
{/* Spread the generated attributes onto your input */}
{/* Tip: Spread fields FIRST so you can override them if needed */}
<input {...fields.email} placeholder="you@example.com" />
<label htmlFor={fields.password.id}>Password</label>
<input {...fields.password} />
<button type="submit">Log In</button>
</Form>
);
}
Since Form relies on native form behavior, you can pre-fill data using the standard defaultValue attribute on your inputs.
Alternatively, you can define default values directly in your schema using Valibot's optional (with a default) or by using getFallback to extract defaults from the schema.
// Schema with default value
const SearchSchema = object({
query: optional(string(), "default search"),
});
Async Data Tip: If your data comes from an async source (like an API), defaultValue won't update if the component re-renders. To fix this, add a key prop to the Form (or the inputs) that changes when the data loads. This forces React to re-create the form with the correct values.
function EditProfileForm({ user }) {
const fields = useFormFields(ProfileSchema);
// If user is null initially, don't render or render a loader
if (!user) return <p>Loading...</p>;
return (
// The key ensures the form is re-mounted when the user ID changes
<Form key={user.id} schema={ProfileSchema} onSubmit={saveUser}>
<label htmlFor={fields.name.id}>Name</label>
<input
{...fields.name}
defaultValue={user.name}
/>
<button type="submit">Update</button>
</Form>
);
}
By default, the Form component uses the browser's native reportValidity() API to show validation errors. This provides accessible, localized error messages without any extra code.
If you need a custom UI (like inline error messages or a summary box), you can use the onSchemaIssues prop to capture validation issues and render them yourself. For best accessibility in custom UIs, use aria-invalid and aria-describedby to link inputs to their error messages.
import { useState } from 'react';
import { Form } from '@packages/werkbank/component/form';
import type { SchemaIssues } from 'valibot';
function LoginForm() {
const fields = useFormFields(LoginSchema);
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<Form
schema={LoginSchema}
onSubmit={(data) => {
setErrors({}); // Clear errors on successful submit
console.log("Success:", data);
}}
onSchemaIssues={(issues) => {
const newErrors: Record<string, string> = {};
issues.forEach((issue) => {
const path = issue.path?.[0]?.key as string;
if (path) newErrors[path] = issue.message;
});
setErrors(newErrors);
}}
>
<label htmlFor={fields.email.id}>Email</label>
<input
{...fields.email}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<span id="email-error" className="error">
{errors.email}
</span>
)}
<button type="submit">Log In</button>
</Form>
);
}
Most UI libraries (like MUI, Chakra, or Radix) accept standard HTML attributes, but sometimes they use different prop names. You can still spread the fields, but you might need to map them.
Example with Material UI:
import TextField from '@mui/material/TextField';
function MaterialForm() {
const fields = useFormFields(LoginSchema);
return (
<Form schema={LoginSchema} onSubmit={console.log}>
<TextField
{...fields.email}
label="Email"
// MUI uses 'error' boolean and 'helperText' for messages
error={!!errors.email}
helperText={errors.email}
/>
</Form>
);
}
When you use a Valibot enum_ schema, useFormFields generates helpers for both <select> menus and Radio groups.
import { object, enum_ } from 'valibot';
// 1. Define your Enum options
const MonsterEnum = {
Kraken: "K",
Sasquatch: "S",
Mothman: "M",
};
// 2. Use enum_ in your schema
const MonsterSchema = object({
favorite: enum_(MonsterEnum), // For Radio Buttons
backup: enum_(MonsterEnum), // For Select Menu
});
function MonsterForm() {
const fields = useFormFields(MonsterSchema);
return (
<Form schema={MonsterSchema} onSubmit={console.log}>
{/* Option A: Radio Buttons */}
<fieldset>
<legend>Choose your favorite monster</legend>
{/* Access specific radio attributes by their Enum key */}
<label>
<input {...fields.favorite.radio.Kraken} /> Kraken
</label>
<label>
<input {...fields.favorite.radio.Sasquatch} /> Sasquatch
</label>
<label>
<input {...fields.favorite.radio.Mothman} /> Mothman
</label>
</fieldset>
{/* Option B: Select Menu */}
<label htmlFor={fields.backup.select.id}>Select a backup monster</label>
<select {...fields.backup.select}>
{/* Map over the generated options array */}
{fields.backup.options.map((option) => (
<option key={option.value} value={option.value}>
{option.name}
</option>
))}
</select>
<button type="submit">Submit</button>
</Form>
);
}
For boolean values, use boolean(). This generates attributes suitable for a checkbox (e.g., type="checkbox").
Note: The Form component automatically handles the conversion of checkbox state (checked/unchecked) into a boolean true/false in the submitted data.
import { object, boolean } from 'valibot';
const TermsSchema = object({
accepted: boolean(),
});
function TermsForm() {
const fields = useFormFields(TermsSchema);
return (
<Form schema={TermsSchema} onSubmit={console.log}>
<label>
<input {...fields.accepted} />
I accept the terms and conditions
</label>
<button type="submit">Continue</button>
</Form>
);
}
You can use nested object() schemas to organize your data. useFormFields mirrors this structure.
import { object, string } from 'valibot';
const UserSchema = object({
name: string(),
address: object({
street: string(),
city: string(),
}),
});
function UserForm() {
const fields = useFormFields(UserSchema);
return (
<Form schema={UserSchema} onSubmit={console.log}>
<input {...fields.name} placeholder="Name" />
{/* Access nested fields via dot notation */}
<input {...fields.address.street} placeholder="Street" />
<input {...fields.address.city} placeholder="City" />
<button type="submit">Save</button>
</Form>
);
}
If your schema requires a field that shouldn't be visible to the user (like an ID or token), you must still include it in the form so it's submitted. Use type="hidden".
const UpdateSchema = object({
id: string(),
content: string(),
});
function UpdateForm({ id }) {
const fields = useFormFields(UpdateSchema);
return (
<Form schema={UpdateSchema} onSubmit={console.log}>
<input {...fields.id} type="hidden" defaultValue={id} />
<textarea {...fields.content} />
<button>Save</button>
</Form>
);
}
Use the regex() validator to enforce custom patterns. This will automatically generate the pattern attribute on the input.
import { object, string, regex } from 'valibot';
const PhoneSchema = object({
// Enforce 10 digits
phone: pipe(string(), regex(/^[0-9]{10}$/)),
});
function PhoneForm() {
const fields = useFormFields(PhoneSchema);
return (
<Form schema={PhoneSchema} onSubmit={console.log}>
<label>Phone Number</label>
{/* Renders <input pattern="^[0-9]{10}$" /> */}
<input {...fields.phone} />
<button type="submit">Call Me</button>
</Form>
);
}
Validate file types and sizes using blob and mimeType. The useFormFields hook automatically sets the accept attribute based on your mimeType validator.
Note: The Form component uses FormData internally, so it automatically handles multipart file uploads without needing manual configuration.
import { pipe, blob, mimeType, maxSize } from 'valibot';
const AvatarSchema = pipe(
blob(),
mimeType(["image/jpeg", "image/png"], "Please select a JPEG or PNG file."),
maxSize(1024 * 1024 * 10, "Please select a file smaller than 10 MB.")
);
const ProfileSchema = object({
avatar: AvatarSchema,
});
function ProfileForm() {
const fields = useFormFields(ProfileSchema);
return (
<Form schema={ProfileSchema} onSubmit={console.log}>
<label htmlFor={fields.avatar.id}>Upload Avatar</label>
{/* Renders <input type="file" accept="image/jpeg, image/png" /> */}
<input {...fields.avatar} />
<button type="submit">Upload</button>
</Form>
);
}
Use date() for standard date inputs. The hook automatically sets type="date".
import { date } from 'valibot';
const EventSchema = object({
eventDate: date(),
});
function EventForm() {
const fields = useFormFields(EventSchema);
return (
<Form schema={EventSchema} onSubmit={console.log}>
<label htmlFor={fields.eventDate.id}>Event Date</label>
{/* Renders <input type="date" /> */}
<input {...fields.eventDate} />
<button type="submit">Save Date</button>
</Form>
);
}
Wrap any schema with optional() to make the field non-mandatory. The required attribute will be automatically omitted from the generated input attributes.
import { object, string, optional } from 'valibot';
const ContactSchema = object({
name: string(), // Required
phone: optional(string()), // Optional
});
function ContactForm() {
const fields = useFormFields(ContactSchema);
return (
<Form schema={ContactSchema} onSubmit={console.log}>
<label htmlFor={fields.name.id}>Name</label>
<input {...fields.name} />
<label htmlFor={fields.phone.id}>Phone (Optional)</label>
{/* 'required' attribute will be false/undefined */}
<input {...fields.phone} />
<button type="submit">Submit</button>
</Form>
);
}
For number() schemas, useFormFields automatically sets inputmode="numeric" and pattern="[0-9]*".
Why not type="number"?
We default to type="text" with inputmode="numeric" to provide a better user experience. As explained by the GOV.UK Design System team, type="number" can cause accessibility issues (like scrolling to change values) and inconsistent behavior across browsers.
import { object, number } from 'valibot';
const AgeSchema = object({
age: number(),
});
function AgeForm() {
const fields = useFormFields(AgeSchema);
return (
<Form schema={AgeSchema} onSubmit={console.log}>
<label htmlFor={fields.age.id}>Age</label>
{/* Renders <input type="text" inputmode="numeric" pattern="[0-9]*" /> */}
<input {...fields.age} />
<button type="submit">Submit</button>
</Form>
);
}
The useFormFields hook maps Valibot validators to HTML attributes as follows:
| Valibot Validator | HTML Attribute | Value |
|---|---|---|
string() |
type |
"text" |
email() |
type |
"email" |
number() |
inputmode |
"numeric" |
number() |
pattern |
"[0-9]*" |
date() |
type |
"date" |
boolean() |
type |
"checkbox" |
blob() |
type |
"file" |
regex(r) |
pattern |
r.source |
mimeType(...) |
accept |
(e.g. "image/jpeg, image/png") |
minLength(n) |
minLength |
n |
maxLength(n) |
maxLength |
n |
minValue(n) |
min |
n |
maxValue(n) |
max |
n |
optional(...) |
required |
(omitted) |
| (Default) | required |
true |
FormThe wrapper component that manages validation and submission.
Props:
schema (Required): The Valibot object schema.onSubmit (Optional): A function called when the form is valid.
data: The parsed and validated data object.event: The native React.FormEvent<HTMLFormElement>.event.preventDefault().onSchemaIssues (Optional): A function called when validation fails.
issues: An array of SchemaIssue objects from Valibot....props: Accepts all standard HTML Form attributes (e.g., className, style, id).useFormFields(schema)A hook that generates input attributes from a schema.
Returns:
An object where each key matches a field in your schema.
name, id, type, required, pattern, accept, etc.radio: Object with keys for each enum option (e.g., radio.Kraken).select: Attributes for the <select> element.options: Array of { name, value } for rendering <option> tags.