Form basics
A form is three things: a valibot schema (the rules), a vee-validate form (the values) and a list of fields (the inputs). Everything used below is auto-imported: v , useForm , toTypedSchema , createFormFields , INPUT_TYPES , moveToError .
Source in the kit: composables/useForm.ts · components/Form/
Your first form
examples/form/Basic.vue Read the four numbered comments in the code: schema, form, fields, submit. Press the button with empty fields to see validation and moveToError .
<template>
<Form @submit="onSubmit">
<FormFields :options="fields" />
<Button
type="submit"
class="mt-4"
:loading="form.isSubmitting.value"
>
Create account
</Button>
</Form>
</template>
<script lang="ts" setup>
// 1. One valibot schema describes the whole form. Messages are Thai by default; pass your own to override.
const schema = v.object({
name: v.pipe(v.string(), v.nonEmpty()),
email: v.pipe(v.string(), v.nonEmpty(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
})
// 2. vee-validate owns the values. `initialValues` keeps every field controlled from the first render.
const form = useForm({
validationSchema: toTypedSchema(schema),
initialValues: {
name: '',
email: '',
password: '',
},
})
// 3. Fields are data: `type` picks the input, `props.name` is the path in the form values.
const fields = createFormFields(() => [
{
type: INPUT_TYPES.TEXT,
props: {
name: 'name',
label: 'Name',
required: true,
},
},
{
type: INPUT_TYPES.TEXT,
props: {
name: 'email',
label: 'Email',
placeholder: '[email protected]',
required: true,
},
},
{
type: INPUT_TYPES.PASSWORD,
props: {
name: 'password',
label: 'Password',
hint: 'At least 8 characters',
required: true,
},
},
])
const submitted = ref<unknown>(null)
// 4. handleSubmit validates first. The second callback runs when invalid: moveToError scrolls to the first error.
const onSubmit = form.handleSubmit((values) => {
submitted.value = values
}, moveToError)
defineExpose({
debug: computed(() => ({
values: form.values,
errors: form.errors.value,
submitted: submitted.value,
})),
})
</script>Validation rules
examples/form/Validation.vue Custom messages, number ranges, optional fields, and a rule across two fields with v.forward .
<template>
<Form @submit="onSubmit">
<FormFields :options="fields" />
<Button
type="submit"
class="mt-4"
>
Change password
</Button>
</Form>
</template>
<script lang="ts" setup>
// Rules that involve several fields live on the object. `v.forward` attaches the error to one field.
const schema = v.pipe(
v.object({
password: v.pipe(v.string(), v.minLength(8, 'Use at least 8 characters')),
confirmPassword: v.pipe(v.string(), v.nonEmpty('Please confirm your password')),
age: v.pipe(v.number('Age is required'), v.minValue(18), v.maxValue(99)),
website: v.optional(v.union([v.literal(''), v.pipe(v.string(), v.url())])),
}),
v.forward(
v.check((input) => input.password === input.confirmPassword, 'The two passwords do not match'),
['confirmPassword']
)
)
const form = useForm({
validationSchema: toTypedSchema(schema),
initialValues: {
password: '',
confirmPassword: '',
website: '',
},
})
const fields = createFormFields(() => [
{
type: INPUT_TYPES.PASSWORD,
props: {
name: 'password',
label: 'New password',
required: true,
},
},
{
type: INPUT_TYPES.PASSWORD,
props: {
name: 'confirmPassword',
label: 'Confirm password',
required: true,
},
},
{
type: INPUT_TYPES.NUMBER,
props: {
name: 'age',
label: 'Age',
description: 'The minValue / maxValue messages come from the Thai dictionary',
required: true,
},
},
{
type: INPUT_TYPES.TEXT,
props: {
name: 'website',
label: 'Website',
placeholder: 'https://',
hint: 'Optional',
},
},
])
const notification = useNotification()
const onSubmit = form.handleSubmit(() => {
notification.success({
title: 'Valid!',
})
}, moveToError)
defineExpose({
debug: computed(() => ({
values: form.values,
errors: form.errors.value,
})),
})
</script>Conditional fields and dynamic labels
examples/form/Conditional.vue Because createFormFields takes a getter, fields can depend on the current values.
<template>
<FormFields :options="fields" />
</template>
<script lang="ts" setup>
const form = useForm({
initialValues: {
customerType: 'person',
name: '',
taxId: '',
hasCoupon: false,
coupon: '',
},
})
// createFormFields takes a getter, so the list is recomputed whenever a value it reads changes.
// `isHide` removes the field from the page; its value stays in the form until you clear it.
const fields = createFormFields(() => [
{
type: INPUT_TYPES.RADIO,
props: {
name: 'customerType',
label: 'Customer type',
orientation: 'horizontal',
options: [
{
label: 'Person',
value: 'person',
},
{
label: 'Company',
value: 'company',
},
],
},
},
{
type: INPUT_TYPES.TEXT,
props: {
name: 'name',
label: form.values.customerType === 'company' ? 'Company name' : 'Full name',
},
},
{
type: INPUT_TYPES.TEXT,
isHide: form.values.customerType !== 'company',
props: {
name: 'taxId',
label: 'Tax ID',
// maska pattern: # is a digit
mask: '#-####-#####-##-#',
},
},
{
type: INPUT_TYPES.TOGGLE,
props: {
name: 'hasCoupon',
label: 'I have a coupon',
},
},
{
type: INPUT_TYPES.TEXT,
isHide: !form.values.hasCoupon,
props: {
name: 'coupon',
label: 'Coupon code',
},
},
])
defineExpose({
debug: computed(() => form.values),
})
</script>Submitting to an API and showing server errors
examples/form/ServerErrors.vue Submit through a loader, react to its status with useWatchTrue , and map INVALID_PARAMS field errors back onto the inputs with form.setErrors .
<template>
<Form @submit="onSubmit">
<FormFields :options="fields" />
<Button
type="submit"
class="mt-4"
:loading="users.add.status.isLoading"
>
Save
</Button>
</Form>
</template>
<script lang="ts" setup>
import { createUsersLoader } from '~/loaders/users'
// The schema only checks the shape. "Email is already taken" can only come from the server:
// submit as is to see it, then change the email to succeed.
const form = useForm({
validationSchema: toTypedSchema(
v.object({
name: v.pipe(v.string(), v.nonEmpty()),
email: v.pipe(v.string(), v.email()),
})
),
initialValues: {
name: 'Ada',
email: '[email protected]',
},
})
const fields = createFormFields(() => [
{
type: INPUT_TYPES.TEXT,
props: {
name: 'name',
label: 'Name',
},
},
{
type: INPUT_TYPES.TEXT,
props: {
name: 'email',
label: 'Email',
},
},
])
const users = createUsersLoader()
const notification = useNotification()
const onSubmit = form.handleSubmit((values) => {
users.addRun({
data: values,
})
}, moveToError)
// A loader never throws: react to its status instead.
useWatchTrue(
() => users.add.status.isSuccess,
() => {
notification.success({
title: `Created ${users.add.item?.name}`,
})
form.resetForm()
}
)
useWatchTrue(
() => users.add.status.isError,
() => {
const error = users.add.status.errorData
// INVALID_PARAMS carries one message per field: put each one under its input
if (ObjectHelper.isInvalidParams(error) && error.fields) {
form.setErrors(
Object.fromEntries(
Object.entries<{ message: string }>(error.fields).map(([name, field]) => [name, field.message])
)
)
return
}
notification.error({
title: StringHelper.getError(error),
})
}
)
defineExpose({
debug: computed(() => ({
status: users.add.status,
errors: form.errors.value,
})),
})
</script>loaders/users.ts
import { defineStore } from 'pinia'
// One file per API resource: its types, then one store per loader.
// `defineStore` makes the loader a singleton, so a list page and its edit form share the same state.
export interface IUser {
id: string
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
salary: number
isActive: boolean
avatar: string
createdAt: string
}
export interface IRole {
id: string
name: string
}
// Paginated CRUD: GET /users, GET /users/:id, POST /users, PUT /users/:id, DELETE /users/:id
export const createUsersLoader = () => {
const options = useRequestOptions()
return usePageLoader<IUser>({
baseURL: '/users',
getBaseRequestOptions: options.getMock,
})
}
// The store is the shared instance: a list page and the form that edits it see the same state.
// Call createUsersLoader() instead when a component wants a private one (each docs example does).
export const useUsersLoader = defineStore('users', createUsersLoader)
// A plain array: GET /roles
export const useRolesLoader = defineStore('roles', () => {
const options = useRequestOptions()
return useListLoader<IRole>({
url: '/roles',
getRequestOptions: options.getMock,
})
})
// A single object, or any one-off request: GET /profile
export const useProfileLoader = defineStore('profile', () => {
const options = useRequestOptions()
return useObjectLoader<IUser & { loadedAt: string }>({
method: 'get',
url: '/profile',
getRequestOptions: options.getMock,
})
})Layout
examples/form/Layout.vueorientation="horizontal" puts the label on the left
Grid: `class` on FormFields lays out the list, `class` on a field spans columns
<template>
<div class="space-y-8">
<div>
<p class="mb-3 text-sm font-semibold text-gray-500">orientation="horizontal" puts the label on the left</p>
<FormFields
:options="fields"
orientation="horizontal"
/>
</div>
<div>
<p class="mb-3 text-sm font-semibold text-gray-500">
Grid: `class` on FormFields lays out the list, `class` on a field spans columns
</p>
<FormFields
:options="gridFields"
class="grid grid-cols-2 gap-x-4"
/>
</div>
</div>
</template>
<script lang="ts" setup>
useForm()
const fields = createFormFields(() => [
{
type: INPUT_TYPES.TEXT,
props: {
name: 'firstName',
label: 'First name',
},
},
{
type: INPUT_TYPES.TEXT,
props: {
name: 'lastName',
label: 'Last name',
},
},
])
const gridFields = createFormFields(() => [
{
type: INPUT_TYPES.TEXT,
props: {
name: 'city',
label: 'City',
},
},
{
type: INPUT_TYPES.TEXT,
props: {
name: 'postcode',
label: 'Postcode',
mask: '#####',
},
},
{
type: INPUT_TYPES.TEXTAREA,
class: 'col-span-2',
props: {
name: 'address',
label: 'Address',
},
},
])
</script>Fields without FormFields
examples/form/Standalone.vue When a list does not fit the design, use the field components directly. Names follow the folder: FormInputText , FormInputSelect , FormInputToggle …
<template>
<Form
class="space-y-4"
@submit="onSubmit"
>
<!-- Every field is also a component: use them directly when the layout is not a simple list -->
<div class="grid grid-cols-2 gap-4">
<FormInputText
name="firstName"
label="First name"
/>
<FormInputText
name="lastName"
label="Last name"
/>
</div>
<Card>
<p class="mb-3 font-semibold">Notifications</p>
<div class="space-y-3">
<FormInputToggle
name="notify.email"
label="Email me"
/>
<FormInputToggle
name="notify.sms"
label="Text me"
/>
</div>
</Card>
<Button type="submit">Save</Button>
</Form>
</template>
<script lang="ts" setup>
// Fields find the nearest useForm() through injection, so no `form` prop is needed here.
// Dots in `name` build nested values: notify.email -> { notify: { email } }
const form = useForm({
initialValues: {
firstName: '',
lastName: '',
notify: {
email: true,
sms: false,
},
},
})
const onSubmit = form.handleSubmit(() => {})
defineExpose({
debug: computed(() => form.values),
})
</script>Writing your own field
examples/form/CustomField.vueA field is any component that calls useFieldHOC
<template>
<FormFields :options="fields" />
</template>
<script lang="ts" setup>
import ColorSwatchField from '~/components/form/ColorSwatchField.vue'
const form = useForm({
initialValues: {
color: '#3675FB',
},
})
// INPUT_TYPES.COMPONENT renders `props.component` and hands it all of `props`.
// The field itself is in playground/components/form/ColorSwatchField.vue (printed below this example).
const fields = createFormFields(() => [
{
type: INPUT_TYPES.COMPONENT,
props: {
// The component goes inside props, next to the props it will receive
component: ColorSwatchField,
name: 'color',
label: 'Brand colour',
description: 'A field is any component that calls useFieldHOC',
colors: ['#3675FB', '#22C55E', '#F59E0B', '#EF4444'],
},
},
])
defineExpose({
debug: computed(() => form.values),
})
</script>components/form/ColorSwatchField.vue
// not found: components/form/ColorSwatchField.vue
