Skip to content

Validate the fields inside it together, submit only valid values, and put server errors on the right field.

import { BbForm } from 'bitboss-ui';

On this page

Use it for

Use BbForm when the user must fix every invalid field before an action runs. The form controls inside it join it by their name. They check themselves against string rules or one schema, and @submit fires once all of them pass.

Use something else when

  • A plain <form> — when nothing needs checking in the browser; outside a BbForm the same controls are plain, and rules do nothing
  • useBbFormField — a control you built yourself has to join the form

Also see: Validated forms, for the rules module, field names and paths, when a field checks itself, and the required asterisk.

BbForm needs the vee-validate peer (npm i vee-validate). It renders a bare <form> and nothing else: spacing, columns and the submit button are yours.

A form, end to end

Give each field a label and a name, add its rules, and handle one @submit.

Create your workspace

Free for 14 days. No card needed.

At least 8 characters.

Press Create workspace with the form empty: every field shows its message, and focus moves to the first one to fix. Fill it in and the values reach @submit, keyed by name. Return the request's promise and the form locks until it settles: the fields turn read-only and the submit button spins, with nothing to wire (While it saves).

  • No v-model on the fields. The form holds the values; bind one only where the page owns that value.
  • No @submit.prevent. Native submission is always prevented.
  • Only the library's controls are checked. The <form> carries novalidate, so the browser's bubbles never show. A plain <input> inside it is validated by nobody.
  • It validates only what you give it. Write rules="required", or require the key in the schema, to check a field on the client. The required prop on a field joined to the form is a hint: it marks the field and checks nothing, which is the server-validated form.
  • A control that is not form data (a filter, a "show advanced" switch) takes formless.
  • A <button type="reset"> works. It runs resetForm(): every field goes back to the value it was mounted with.

The rule names (required, email, min) are registered once for the whole app: copy the module from Validated forms → Define the rules once.

One schema for the whole form

Pass a Zod, Valibot, ArkType or Yup schema to schema as it is, with no adapter, and leave rules off the fields.

Change password

At least 12 characters.

Try two different new passwords first.

Each name is a key of the schema, and the refine puts its message on confirm through its path. @submit receives the schema's output, typed: write the handler as (values: z.output<typeof schema>) => ….

  • Give the type its message too, as in z.string('Enter your current password.'). An emptied field holds null, and without it the user reads "expected string, received null".
  • Declare every key you submit. Zod and Valibot drop the others, so an undeclared checkbox never reaches @submit.
  • One source of rules per form. While schema is set, rules on the fields are ignored.
  • A form checked two ways (save as draft, publish) switches a computed schema: computed(() => (publishing.value ? publish : draft)).

The asterisk follows the schema as it follows rules: see Validated forms → The required asterisk.

Editing a loaded record

Pass the loaded object as :model. The fields start from it and write every change back into it in place, so the rest of the page reads it live. The form emits no update:modelValue; to react to a change, watch the object with { deep: true }.

Up to 160 characters.
All changes saved

The slot's dirty enables Save profile and Discard. reset() from useBbFormContext(id) (the slot's resetForm) puts the loaded values back, and snapshot() after a save makes the saved values the new baseline, so dirty clears. The object is updated in place, never replaced: with Inertia, bind the useForm() object and declare every field in it.

  • A draft skips @submit. Post useBbFormContext(id).values.value from a type="button", then call snapshot(). Nothing is validated, and the real save still goes through @submit.

Gate Save profile on dirty, not on valid. A form nobody has touched counts as invalid, so a button disabled until the form is valid looks broken on first paint.

While it saves

Return the promise from @submit and the form treats the save as running until it settles. Every field joined to the form turns read-only and the submit button that submitted spins, so nobody edits a value already on its way or presses the button twice.

Press Log time and try to type while it saves. Then turn on the switch and save again: with disable-submit-lock the fields stay editable, and the button still spins until the save settles. The slot's isSubmitting follows the save either way.

  • Read-only, never disabled. The values stay visible and focus stays where it was; nothing greys out, and assistive tech hears aria-readonly. When the promise settles each field goes back to its own readonly.
  • Drop a hand-wired :loading="isSubmitting" on a submit button inside the form: it is redundant. isSubmitting in the slot (submitting on useBbFormContext) is still there for anything else you show meanwhile, such as the "Saving…" line above.
  • Only the button that submitted spins. A second submit button, Cancel and a row action stay as they are. A submit button outside the <form> that names it with form="…", in a dialog footer, follows the same rule with no :loading to bind (Driving the form from outside). A submit from code (submit()) spins no submit button: the button whose click calls it spins on its own. One with disable-auto-loading never spins on its own, not even for the save.
  • The dialog around it locks too. In a BbDialog or a BbOffCanvas, the surface refuses every close (the ×, Cancel, Escape, the backdrop) until the save settles, so nobody walks away from a request in flight. With disable-submit-lock it stays closable.
  • A synchronous @submit never locks. One that throws or rejects still ends the save, so nothing spins forever.
  • A control of your own reads the lock from its useBbFormField handle's readonly.

Two actions, one form

Enter in a field presses the form's first submit button: that is the HTML rule, and there is no other way to choose it. So the primary action is the form's only type="submit" button, and a secondary action ("Save & new") is a plain button whose click calls the form's submit().

0 saved

  • submit() says whether the save went through. It resolves true after a save and false when the form is invalid or the save failed, so if (await form.submit()) form.reset() clears only a saved form.
  • The plain button spins while its save runs, because its click returns the promise. Save stays still, and the form locks the same either way.
  • @submit gets the submitter as its second argument, { submitter } (null for a submit from code). When two submit buttons do share one handler, tell them apart by a name you put on each: submitter?.getAttribute('name') === 'again'.

Server errors

Hand a 422 to the form in one call, setErrors(errors), from the slot or useBbFormContext(id): each message lands on the field with that path.

Invite a teammate

Editor

Send it as it is: the server has something to say.

setErrors takes Laravel's { field: string[] } and Inertia's { field: string }, keyed by path. Focus moves to the first field that failed. A key no field owns, seats here, goes to unmatchedErrors and only there: render it, or the user never sees it.

Each call replaces the errors of the one before. Merge a message about the whole request into the same map, under a key no field uses: setErrors({ ...body.errors, _request: [body.message] }).

When something on the page already holds the errors, such as Inertia's form.errors or Precognition's, bind them with :field-errors instead. A message shows for as long as it is in that object, so its owner clears it, and it never blocks a submit.

The slot's errors holds every message of a field, string[] per path: read errors.email?.[0] for the first. errors and unmatchedErrors never share a path, so { ...errors, ...unmatchedErrors } is one summary of every error.

Also see: Validated forms → Server errors, for Inertia's useForm, Precognition, Nuxt UI and API Platform.

Lines the user adds and removes

Keep the rows in the bound object, name each field by its index, and key the v-for by id.

Restock request

Aeris Ergonomic Task Chair
Pick a product

Send it with the second line empty, then remove that line: its error goes with it. Adding a line is a push, removing one is a splice. The form follows the array, and a row that moves up keeps its own value and error. A Laravel 422 answers with the same path, lines.1.qty.

validation-label gives a message its noun when the label is not one: "Quantity must be at least 1", not "Quantity, line 2 must be at least 1".

A form in steps

Give each step its own schema, :schema="steps[step]", and keep what was typed on the steps you leave with keep-values-on-unmount.

  1. 1. Account
  2. 2. Company
  3. 3. Plan

Create your account

The submit button reads Next until the last step, so moving on is a submit the step's schema must pass. The last schema covers the whole signup, and @submit receives all of it. Keep the steps array as const, or @submit is typed as the first step.

When the last check fails on a field of an earlier step, @invalid hands you every failing path: switch to that step and the form focuses the field once it is on screen. It fires for server errors too (setErrors, or field-errors bringing a message) with source: 'server' before the form moves focus, and its unmatched lists the paths no field claims; a failed submit says source: 'submit'.

A field that stops applying (a v-if on "business account") keeps its value in a form like this. Call removeField('vat') when you hide it for good, or its old value is still submitted and checked.

Driving the form from outside

When the button lives outside the <form> element, in a dialog footer or a page toolbar, give the form an id. A submit button names it with the native form attribute: <BbButton form="invite-teammate" type="submit"> submits it through the same @submit and validation, and spins while the save runs, like a submit button inside it.

The footer button lives outside the form element.

The @submit closes the dialog with complete, which hands the address to the button that opened it. For anything else, drive the form with useBbFormContext(id), called once at the top of <script setup>. Its state is refs (invite.submitting.value); its actions are submit(), reset(), validate(), setErrors(), snapshot() and removeField().

  • The id must be one you set. A form without one has no outside handle, which is right for a self-contained form.
  • It is safe before the form mounts. State reads a stable baseline (false, 0, {}) and actions do nothing until isReady is true, so a footer rendered ahead of the dialog body is safe. Watch isReady if you must act on mount.
  • There is no template-ref API. A ref gives only $el, the native <form>. Never call $el.submit(): it skips validation and reloads the page.