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 .

At least 8 characters
<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 .

The minValue / maxValue messages come from the Thai dictionary

Optional
<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>

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.vue

orientation="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 …

Notifications

<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.vue

A 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