werkbank
    Preparing search index...

    Getting Started with Form

    The Form component and useFormFields hook provide a type-safe, schema-first way to build forms using Valibot.

    • Valibot: You must define your form structure using a Valibot object schema.
    • React: Requires React 18+ for hooks support.

    Instead of manually managing state, event listeners, and validation logic for every input, you define a Schema that represents your data.

    1. Validation: The 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.
    2. Attributes: The 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.

    1. Import the necessary validators from valibot.
    2. Define an object schema where each key is a field name.
    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

    The 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>.
      • Behavior: Automatically calls 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).

    A hook that generates input attributes from a schema.

    Returns:

    An object where each key matches a field in your schema.

    • Standard Fields: Contains name, id, type, required, pattern, accept, etc.
    • Enum Fields: Contains:
      • 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.