Skip to content

Validated forms

The form controls join the BbForm around them and validate with vee-validate string rules or one Standard Schema.

On this page

Wrap the form controls in a BbForm and each one joins it. Its value is submitted and reset with the form, counted in dirty and checked against its rules or the form's schema. Outside a BbForm the same controls are plain, and rules do nothing.

BbForm validates only what you give it. With no schema and no rules it checks nothing on the client: @submit always fires and the server decides (Server errors). A control's required is a hint, never a check: it shows the asterisk and tells assistive technology, and an empty field still submits.

This page is the setup around the form. The BbForm page covers the form itself: schemas, edit forms, rows, steps and driving it from outside.

Install, then wrap the fields

Install the vee-validate peer. An app that renders no BbForm needs none of it, and a build that uses BbForm without it fails with the install command.

bash
# the peer BbForm needs (>=4.10 <5)
npm i vee-validate
# only for string rules
npm i @vee-validate/rules @vee-validate/i18n

Everything comes from bitboss-ui: BbForm, the controls, useBbFormContext and useBbFormField. Sixteen controls join a form by themselves:

BbTextInput, BbNumberInput, BbTextarea, BbSelect, BbTag, BbCheckbox, BbCheckboxGroup, BbSwitch, BbSwitchGroup, BbRadioGroup, BbRating, BbSlider, BbDatePickerInput, BbTimePickerInput, BbColorInput and BbDropzone.

  • Panels never join: BbSelectPopover, BbDatePicker, BbTimePicker, BbColorPalette. Use their field twins in a form.
  • BbRadio and BbCalendar do not join either. Validate a choice with BbRadioGroup and a date with BbDatePickerInput, or bind them with useBbFormField.
  • formless keeps a control out: not submitted, not reset, not checked. Use it for a filter or a "show advanced" switch that sits inside the form without joining it.

Define the rules once

Register your rules with defineRule in one module, import it once in the app's entry file, and name them as strings on every field.

ts
// app/validators.ts
import { configure, defineRule } from 'vee-validate';
import { localize, setLocale } from '@vee-validate/i18n';
import en from '@vee-validate/i18n/dist/locale/en.json';
import {
    required,
    email,
    min_value,
    max_value,
    numeric,
    min,
    max,
} from '@vee-validate/rules';

defineRule('required', required);
defineRule('email', email);
defineRule('min_value', min_value);
defineRule('max_value', max_value);
defineRule('numeric', numeric);
defineRule('min', min);
defineRule('max', max);

// Project rules use the same (value, params, ctx) signature. Like the
// built-in rules, an empty value passes: pair it with `required`.
defineRule('password', (v: unknown) => {
    if (typeof v !== 'string' || v === '') return true;
    return v.length >= 8 || 'Password must be at least 8 characters';
});

configure({ generateMessage: localize({ en }) });
setLocale('en');
ts
// main.ts: imported once, for its side effects
import './app/validators';

A page then writes rules="required|email" and never imports vee-validate. npx bitboss-ui check flags a string rule the project never registers (unregistered-rule). For a one-off check that is not worth a name, a function works inline: :rules="(v) => Number(v) % 5 === 0 || 'In blocks of five.'".

Or one schema for the whole form

If your app already describes its data with Zod, Valibot, ArkType or Yup, pass that schema to BbForm's schema and write no rules at all.

vue
<script setup lang="ts">
import { z } from 'zod';
import { BbButton, BbForm, BbTextInput } from 'bitboss-ui';

const signup = z.object({
    email: z.email('Enter a valid email address.'),
    address: z.object({
        city: z.string('Enter a city.').trim().min(1, 'Enter a city.'),
    }),
});

const save = async (values: z.output<typeof signup>) => { /* … */ };
</script>

<template>
    <BbForm :schema="signup" @submit="save">
        <BbTextInput label="Email" name="email" type="email" />
        <BbTextInput label="City" name="address[city]" />
        <BbButton type="submit">Save</BbButton>
    </BbForm>
</template>

Any Standard Schema works as it is: no @vee-validate/zod, nothing else to install. It has been checked against Zod 3.24 and 4, Valibot 1, ArkType 2, Yup 1.7, Joi 18 and Effect Schema 3.

LibraryMessage on an emptied text field
Zod 4z.string('Required').min(1, 'Required')
Zod 3z.string({ invalid_type_error: 'Required' })
Valibotv.pipe(v.string('Required'), v.minLength(1, 'Required'))
ArkTypetype('string').configure({ message: 'Required' })
Yup 1.7+.required('Required') already covers it
Joi.required().messages({ '*': 'Required' })
EffectSchema.String.annotations({ message: () => 'Required' })
  • Zod 3 also takes required_error: 'Required' for a key that is missing.
  • ArkType gives one combined message per field and has no async checks.
  • Yup below 1.7 has no Standard Schema: upgrade.
  • Joi needs .prefs({ abortEarly: false }) to report every field. It has no async checks, and @submit is untyped.
  • Effect is a Standard Schema only once wrapped: :schema="Schema.standardSchemaV1(schema)". It gives one message per field.
  • Superstruct, TypeBox and runtypes have no Standard Schema. Wrap one in a vee-validate TypedSchema yourself, or use a library above.

An emptied BbTextInput or BbTextarea holds null, which is why the table puts the message on the type. To write z.string().min(1, 'Required') instead, set the plugin option formControls: { textInputNullValue: 'string', textareaNullValue: 'string' } and start your text values as ''. A BbNumberInput already holds a number: use z.number(), not a coercion.

With a schema, set name on every field, since it is the key the schema checks, and declare every key you submit. Zod and Valibot drop the rest from @submit.

Seed only what you have. An object bound with :model holds the loaded data and real defaults (a quantity of 1). Leave out what the user must choose, such as the terms checkbox, so its message shows. Type that object by what you seed, not as the schema's output: terms: false is not true.

Field names and paths

A field's path is its name. Without one it is the label, slugged ("Preferred time" becomes preferredtime), which is enough for a throwaway form and wrong for a real one.

Pick a plan

Submitted values

Nothing yet. Submit with 0 people first.

name is read as a path in either spelling: company[name] and company.name are the same field, lines[0][qty] is lines.0.qty, and a trailing [] goes (tags[] is tags). The submitted values nest to match. Set name always when:

  • the label repeats, as in rows of a v-for: :name="`attendees[${i}]`";
  • the label is translated or may change. A label-derived path moves with the text, and server errors, drafts and analytics keyed by it stop matching;
  • the control has no label. A BbDropzone inside a form needs a name, or it throws;
  • the same group renders twice, such as billing and shipping addresses. Give your group component a prefix prop and name its fields :name="`${prefix}[street]`": the values nest as billing.street and shipping.street.

name also goes on the native input, so the same field posts under it in a plain HTML form. For a native post to PHP or Laravel, use brackets: address.city arrives as address_city, address[city] as a nested array.

Messages name a field by its label, never by its path. Set validation-label when the label is not the noun a sentence needs: above, "How many people will use it?" reads as "Seats must be at least 1". A field with neither says "This field".

Coming from v2vid → name / validation-label

v2's vid named the field in messages: that is validation-label now. Where vid set the field's key, write name with the same value. A leftover vid has no effect at runtime and a development build warns; npx bitboss-ui check --fix rewrites each one by its value.

When a field checks itself

Every control follows one validation-mode, and the default, balanced, suits most forms. Pick a mode and try it on the fields below.

Validation mode

Type one letter in Username, then leave the field.

ModeFirst checkWhile it shows an errorOnce fixed
balancedleaveevery changewaits for leave
aggressiveevery changeevery changeevery change
lazyleaveleave onlyleave
submit-onlysubmitthe next submitthe next submit
submit-livesubmitevery changequiet until the next submit

Leaving the field means blur for a text-like control, and focus leaving the whole control for a picker or a group. A checkbox or switch click counts as the change and the leave at once. Whatever the mode, a failed submit checks every field. When the page writes a value itself, a field that shows an error is checked again, except in lazy and submit-only.

Set the mode on one field, on the whole BbForm (validation-mode="submit-live"), or app-wide with the plugin option formControls: { validationMode: 'lazy' }. The field's own wins, then the form's.

  • balanced for most forms.
  • submit-only or submit-live for long forms that should not nag while they are being filled in.
  • lazy for expensive async checks, such as "is this username free".
  • aggressive only on purpose: it flags half-typed values.

Server errors

Two ways to hand a server's validation errors to the form. Both take paths as field names, with dots or brackets.

  • :field-errors="errors" when something on the page already holds them: Inertia's form.errors, Precognition's, a ref you fill from a 422. A message shows while it is in the object, so the provider clears it, not the form. It never blocks a submit.
  • setErrors(errors) for a one-off answer. Call it on useBbFormContext(id) or from the BbForm slot. It takes { path: string | string[] }, and the form drops a message when the user edits its field.

Each message lands on its field, and focus moves to the first one that failed. A key no field owns goes to unmatchedErrors: render it yourself.

SourceIts errorsInto the form
Inertia visit$page.props.errors, { key: string }:field-errors="$page.props.errors"
Inertia, error bag$page.props.errors[bag]:field-errors="$page.props.errors.<bag>"
Inertia useForminertiaForm.errors:field-errors="inertiaForm.errors"
Laravel 422body.errors, { key: string[] }errors.value = body.errors, then :field-errors="errors"
PrecognitionprecognitionForm.errors:field-errors="precognitionForm.errors"
vee-validateerrorBag, { key: string[] }form.setErrors(errorBag.value)
Nuxt UI UFormFormError[]form.setErrors(byPath(list))
API Platformbody.violationsbyPath, see below
vue
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { BbButton, BbForm, BbTextInput } from 'bitboss-ui';

const form = useForm({ email: '', address: { city: '' } });
</script>

<template>
    <BbForm :model="form" :field-errors="form.errors" @submit="form.post('/signup')">
        <BbTextInput label="Email" name="email" type="email" />
        <BbTextInput label="City" name="address.city" />
        <BbButton type="submit">Create account</BbButton>
    </BbForm>
</template>
ts
// fetch or axios, a Laravel 422: keep the answer in a ref
const errors = ref<Record<string, string[]> | null>(null);

async function save(values: Record<string, unknown>) {
    errors.value = null;
    const response = await fetch('/api/signup', {
        method: 'POST',
        body: JSON.stringify(values),
    });
    if (response.status === 422) errors.value = (await response.json()).errors;
}

// Nuxt UI FormError[] and API Platform violations: group by path
const byPath = (list: { name?: string; message: string }[]) =>
    list.reduce<Record<string, string[]>>((map, { name, message }) => {
        (map[name || '_request'] ??= []).push(message);
        return map;
    }, {});
form.setErrors(byPath(formErrors));
form.setErrors(
    byPath(
        body.violations.map((v) => ({
            name: v.propertyPath,
            message: v.message,
        })),
    ),
);
  • A message about the whole request (a 403, a 500 text) has no path. Put it in the same map under a key no field uses, since each setErrors call replaces the last: form.setErrors({ ...body.errors, _request: [body.message] }).
  • One summary of every error is { ...errors, ...unmatchedErrors }: the two never share a path. errors.email is an array; read errors.email?.[0].

A worked example is in BbForm → Server errors, and the Inertia side in Inertia and Laravel.

For one field's message from elsewhere, the control's own errors prop still works: it is merged with the rule messages, not replaced by them.

The required asterisk

Required fields carry a red * after the label, out of the box. The plugin option requiredAsterisk (default true) turns it off app-wide:

ts
// vite.config.ts: only to hide the marker everywhere
bitbossUi({ requiredAsterisk: false });
Optional. Only for delivery questions.

Inside a BbForm it shows on a control whose rules contain a required rule, in the string form ('required|email', 'required:true') or the object form ({ required: true }), or whose key the schema requires. Outside a form it follows the required prop.

With a schema, the schema decides. A key gets the asterisk when it is not optional, has no default and is not nullable, and its parent object is required too. The marker follows a schema that changes per step.

Inside a BbForm, the required prop is a hint. It shows the asterisk and tells assistive technology the field is required, and checks nothing: the form posts with novalidate and vee-validate never reads the attribute, so an empty field submits and the server answers. That is the server-validated form:

vue
<BbForm :model="draft" :field-errors="form.errors" @submit="save">
    <!-- the server checks it; the asterisk says so -->
    <BbTextInput name="email" label="Email" required />
</BbForm>

To stop an empty submit on the client too, write the requirement where the form validates, and the asterisk follows:

vue
<BbTextInput name="email" label="Email" rules="required|email" />
<!-- a condition goes in the rules too -->
<BbTextInput name="vat" label="VAT number" :rules="{ required: isCompany }" />

Only required raises the marker, on purpose. Every other rule passes on an empty field, which is what makes "optional, but at most 280 characters if filled" expressible. required_if and its relatives do not count either.

A validator function is opaque to the marker: nothing can read what it does with an empty value. Add the required hint beside it to mark the field, or make the requirement a client check: register the function as a named rule and list it after required (rules="required|vat"), or require the key in the schema.

The marker is BbAsterisk, rendered aria-hidden; drop it into a label you build yourself. Inside a form, a field its rules or schema require gets aria-required by itself, so assistive technology announces it.

A control of your own

useBbFormField(formId, { name, rules }) makes a control you built a field of the form: submitted, reset, counted in dirty and focused on a failed submit.

Name the workspace and press Save appearance without a colour: focus lands on the swatches. Bind controlAttrs once, on the element that is the field (here the swatch group), and errorAttrs on the message. A custom field follows the form's validation-mode like any other. Assigning value is the change; call handleBlur() when the user is done with the control (here, focus leaving the group) and that is the leave. Without it the field is first checked at submit, and lazy waits for it. Do not call validate() on every change: that is aggressive in any form.

  • With a schema on the form, the field's rules are ignored: put its key in the schema, accent: z.string('Pick an accent colour.').
  • Wrapping a library control instead? Its three form props are typed as FormFieldProps, and the mode as ValidationMode, both from bitboss-ui.

For Inertia submissions, continue with Forms that return promises.