10.4k

Formisch

PreviousNext

Build forms in Vue using Formisch and Valibot.

This guide covers building forms with Formisch, the lightweight, schema-first, and fully type-safe form library for Vue. We'll create forms with the Field component, validate them with Valibot schemas, handle errors, and ensure accessibility.

Demo

We'll build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.

Bug Report

Help us improve by reporting bugs you encounter.

0/100 characters

Include steps to reproduce, expected behavior, and what actually happened.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from '@/components/ui/input-group'

const FormSchema = v.object({
  title: v.pipe(
    v.string(),
    v.minLength(5, 'Bug title must be at least 5 characters.'),
    v.maxLength(32, 'Bug title must be at most 32 characters.'),
  ),
  description: v.pipe(
    v.string(),
    v.minLength(20, 'Description must be at least 20 characters.'),
    v.maxLength(100, 'Description must be at most 100 characters.'),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    title: '',
    description: '',
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h(
      'pre',
      {
        class:
          'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4',
      },
      h('code', JSON.stringify(output, null, 2)),
    ),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Bug Report</CardTitle>
      <CardDescription>
        Help us improve by reporting bugs you encounter.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-demo" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['title']">
            <Field :data-invalid="field.errors !== null">
              <FieldLabel for="form-formisch-demo-title">
                Bug Title
              </FieldLabel>
              <Input
                id="form-formisch-demo-title"
                v-model="field.input"
                v-bind="field.props"
                :aria-invalid="field.errors !== null"
                placeholder="Login button not working on mobile"
                autocomplete="off"
              />
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>

          <FormischField v-slot="field" :of="form" :path="['description']">
            <Field :data-invalid="field.errors !== null">
              <FieldLabel for="form-formisch-demo-description">
                Description
              </FieldLabel>
              <InputGroup>
                <InputGroupTextarea
                  id="form-formisch-demo-description"
                  v-model="field.input"
                  v-bind="field.props"
                  placeholder="I'm having an issue with the login button on mobile."
                  :rows="6"
                  class="min-h-24 resize-none"
                  :aria-invalid="field.errors !== null"
                />
                <InputGroupAddon align="block-end">
                  <InputGroupText class="tabular-nums">
                    {{ (field.input ?? '').length }}/100 characters
                  </InputGroupText>
                </InputGroupAddon>
              </InputGroup>
              <FieldDescription>
                Include steps to reproduce, expected behavior, and what
                actually happened.
              </FieldDescription>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-demo">
          Submit
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Approach

This form leverages Formisch for headless, schema-first form handling. We'll build our form using the Field component, which gives you complete flexibility over the markup and styling.

  • Uses Formisch's useForm composable for form state management.
  • Form component to wrap the native form element with submit handling.
  • Field component with a slot prop for controlled inputs.
  • Schema validation using Valibot.
  • Type-safe field paths inferred from the schema.

Form Methods

Formisch exposes form operations as top-level functions rather than methods on a form object. Import only what you need:

import { getInput, insert, reset, submit } from '@formisch/vue'

Every method follows the same signature: the first parameter is always the form store, and the second parameter (if necessary) is always a config object.

// Read a field value
const email = getInput(form, { path: ['email'] })

// Reset the form with new initial values
reset(form, { initialInput: { email: '', password: '' } })

// Move an item in a field array
move(form, { path: ['items'], from: 0, to: 3 })

This design keeps the API flexible and consistent across all methods. You'll see the same (form, config) shape used throughout this guide for reading state (getInput, getErrors), writing state (setInput, setErrors), form control (submit, validate, focus), and array operations (insert, remove, move, swap, replace). See the full methods reference for details.

Anatomy

Here's a basic example of a form using the Field component from Formisch and the shadcn-vue Field component.

<template>
  <Form :of="form" @submit="handleSubmit">
    <FieldGroup>
      <FormischField :of="form" :path="['title']" v-slot="field">
        <Field :data-invalid="field.errors !== null">
          <FieldLabel for="form-title">Bug Title</FieldLabel>
          <Input
            v-model="field.input"
            v-bind="field.props"
            id="form-title"
            :aria-invalid="field.errors !== null"
            placeholder="Login button not working on mobile"
            autocomplete="off"
          />
          <FieldDescription>
            Provide a concise title for your bug report.
          </FieldDescription>
          <FieldError
            v-if="field.errors"
            :errors="field.errors.map((message) => ({ message }))"
          />
        </Field>
      </FormischField>
    </FieldGroup>
  </Form>
</template>

Form

Create a form schema

We'll start by defining the shape of our form using a Valibot schema. Formisch infers all input and output types directly from this schema.

<script setup lang="ts">
import * as v from 'valibot'

const FormSchema = v.object({
  title: v.pipe(
    v.string(),
    v.minLength(5, 'Bug title must be at least 5 characters.'),
    v.maxLength(32, 'Bug title must be at most 32 characters.'),
  ),
  description: v.pipe(
    v.string(),
    v.minLength(20, 'Description must be at least 20 characters.'),
    v.maxLength(100, 'Description must be at most 100 characters.'),
  ),
})
</script>

Setup the form

Next, we'll use the useForm composable from Formisch to create our form instance. The schema is passed directly to useForm — there is no resolver step.

<script setup lang="ts">
import { Field as FormischField, Form, useForm } from '@formisch/vue'
import type { SubmitHandler } from '@formisch/vue'
import * as v from 'valibot'

const FormSchema = v.object({
  // ...
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    title: '',
    description: '',
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  // Do something with the validated form values.
  console.log(output)
}
</script>

<template>
  <Form :of="form" @submit="handleSubmit">
    <!-- ... -->
  </Form>
</template>

The Form component wraps a native form element. It calls event.preventDefault(), runs validation, and only invokes the submit handler when the data is valid. The output you receive is fully typed from the schema.

Build the form

We can now build the form using the FormischField component from Formisch and the Field components.

Form.vue
<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from '@/components/ui/input-group'

const FormSchema = v.object({
  title: v.pipe(
    v.string(),
    v.minLength(5, 'Bug title must be at least 5 characters.'),
    v.maxLength(32, 'Bug title must be at most 32 characters.'),
  ),
  description: v.pipe(
    v.string(),
    v.minLength(20, 'Description must be at least 20 characters.'),
    v.maxLength(100, 'Description must be at most 100 characters.'),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    title: '',
    description: '',
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h(
      'pre',
      {
        class:
          'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4',
      },
      h('code', JSON.stringify(output, null, 2)),
    ),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Bug Report</CardTitle>
      <CardDescription>
        Help us improve by reporting bugs you encounter.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-demo" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['title']">
            <Field :data-invalid="field.errors !== null">
              <FieldLabel for="form-formisch-demo-title">
                Bug Title
              </FieldLabel>
              <Input
                id="form-formisch-demo-title"
                v-model="field.input"
                v-bind="field.props"
                :aria-invalid="field.errors !== null"
                placeholder="Login button not working on mobile"
                autocomplete="off"
              />
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>

          <FormischField v-slot="field" :of="form" :path="['description']">
            <Field :data-invalid="field.errors !== null">
              <FieldLabel for="form-formisch-demo-description">
                Description
              </FieldLabel>
              <InputGroup>
                <InputGroupTextarea
                  id="form-formisch-demo-description"
                  v-model="field.input"
                  v-bind="field.props"
                  placeholder="I'm having an issue with the login button on mobile."
                  :rows="6"
                  class="min-h-24 resize-none"
                  :aria-invalid="field.errors !== null"
                />
                <InputGroupAddon align="block-end">
                  <InputGroupText class="tabular-nums">
                    {{ (field.input ?? '').length }}/100 characters
                  </InputGroupText>
                </InputGroupAddon>
              </InputGroup>
              <FieldDescription>
                Include steps to reproduce, expected behavior, and what
                actually happened.
              </FieldDescription>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-demo">
          Submit
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Done

That's it. You now have a fully accessible form with client-side validation.

When you submit the form, the handleSubmit function will be called with the validated form data. If the form data is invalid, Formisch will populate field.errors for each invalid field and the UI will display them.

Validation

Client-side Validation

Formisch validates your form data using the Valibot schema you pass to useForm. There is no resolver — the schema is the single source of truth for both runtime validation and static types.

<script setup lang="ts">
import { useForm } from '@formisch/vue'
import * as v from 'valibot'

const FormSchema = v.object({
  title: v.string(),
  description: v.optional(v.string()),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    title: '',
    description: '',
  },
})
</script>

Validation Modes

Formisch separates the first validation from subsequent validations. You configure them with the validate and revalidate options on useForm.

<script setup lang="ts">
const form = useForm({
  schema: FormSchema,
  validate: 'blur',
  revalidate: 'input',
})
</script>
OptionValueDescription
validate'submit'Validate on form submission (default).
validate'blur'Validate when a field loses focus.
validate'input'Validate on every input change.
validate'initial'Validate immediately on form creation.
revalidate'input'Revalidate on every input change after the first run (default).
revalidate'blur'Revalidate on blur after the first run.
revalidate'submit'Revalidate only on form submission.

Displaying Errors

Display errors next to the field using FieldError. Formisch returns errors as an array of strings, so map them to the shape FieldError expects. For styling and accessibility:

  • Add the :data-invalid prop to the Field component.
  • Add the :aria-invalid prop to the form control such as Input, SelectTrigger, Checkbox, etc.
<template>
  <FormischField :of="form" :path="['email']" v-slot="field">
    <Field :data-invalid="field.errors !== null">
      <FieldLabel for="form-email">Email</FieldLabel>
      <Input
        v-model="field.input"
        v-bind="field.props"
        id="form-email"
        type="email"
        :aria-invalid="field.errors !== null"
      />
      <FieldError
        v-if="field.errors"
        :errors="field.errors.map((message) => ({ message }))"
      />
    </Field>
  </FormischField>
</template>

Working with Different Field Types

Formisch exposes two ways to bind a field to an element:

  • Native HTML elements (like Input and Textarea) — use v-model="field.input" and spread field.props with v-bind. Formisch wires up name, ref, and the focus and blur events for you.
  • Component-library inputs (like reka-ui based Select, Checkbox, RadioGroup, Switch) — bind field.input directly with v-model or :model-value and @update:model-value.

Input

  • For input fields, use v-model="field.input" and v-bind="field.props".
  • To show errors, add the :aria-invalid prop to the Input component and the :data-invalid prop to the Field component.

Profile Settings

Update your profile information below.

This is your public display name. Must be between 3 and 10 characters. Must only contain letters, numbers, and underscores.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'

const FormSchema = v.object({
  username: v.pipe(
    v.string(),
    v.minLength(3, 'Username must be at least 3 characters.'),
    v.maxLength(10, 'Username must be at most 10 characters.'),
    v.regex(
      /^\w+$/,
      'Username can only contain letters, numbers, and underscores.',
    ),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    username: '',
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Profile Settings</CardTitle>
      <CardDescription>
        Update your profile information below.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-input" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['username']">
            <Field :data-invalid="field.errors !== null">
              <FieldLabel for="form-formisch-input-username">
                Username
              </FieldLabel>
              <Input
                id="form-formisch-input-username"
                v-model="field.input"
                v-bind="field.props"
                :aria-invalid="field.errors !== null"
                placeholder="shadcn"
                autocomplete="username"
              />
              <FieldDescription>
                This is your public display name. Must be between 3 and 10
                characters. Must only contain letters, numbers, and
                underscores.
              </FieldDescription>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-input">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <FormischField :of="form" :path="['username']" v-slot="field">
    <Field :data-invalid="field.errors !== null">
      <FieldLabel for="form-username">Username</FieldLabel>
      <Input
        v-model="field.input"
        v-bind="field.props"
        id="form-username"
        :aria-invalid="field.errors !== null"
      />
      <FieldError
        v-if="field.errors"
        :errors="field.errors.map((message) => ({ message }))"
      />
    </Field>
  </FormischField>
</template>

Textarea

  • For textarea fields, use v-model="field.input" and v-bind="field.props".
  • To show errors, add the :aria-invalid prop to the Textarea component and the :data-invalid prop to the Field component.

Personalization

Customize your experience by telling us more about yourself.

Tell us more about yourself. This will be used to help us personalize your experience.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'

const FormSchema = v.object({
  about: v.pipe(
    v.string(),
    v.minLength(10, 'Please provide at least 10 characters.'),
    v.maxLength(200, 'Please keep it under 200 characters.'),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    about: '',
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Personalization</CardTitle>
      <CardDescription>
        Customize your experience by telling us more about yourself.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-textarea" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['about']">
            <Field :data-invalid="field.errors !== null">
              <FieldLabel for="form-formisch-textarea-about">
                More about you
              </FieldLabel>
              <Textarea
                id="form-formisch-textarea-about"
                v-model="field.input"
                v-bind="field.props"
                :aria-invalid="field.errors !== null"
                placeholder="I'm a software engineer..."
                class="min-h-[120px]"
              />
              <FieldDescription>
                Tell us more about yourself. This will be used to help us
                personalize your experience.
              </FieldDescription>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-textarea">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <FormischField :of="form" :path="['about']" v-slot="field">
    <Field :data-invalid="field.errors !== null">
      <FieldLabel for="form-about">More about you</FieldLabel>
      <Textarea
        v-model="field.input"
        v-bind="field.props"
        id="form-about"
        :aria-invalid="field.errors !== null"
        placeholder="I'm a software engineer..."
        class="min-h-[120px]"
      />
      <FieldDescription>
        Tell us more about yourself. This will be used to help us personalize
        your experience.
      </FieldDescription>
      <FieldError
        v-if="field.errors"
        :errors="field.errors.map((message) => ({ message }))"
      />
    </Field>
  </FormischField>
</template>

Select

  • For select components, bind field.input with v-model on the Select component.
  • To show errors, add the :aria-invalid prop to the SelectTrigger component and the :data-invalid prop to the Field component.

Language Preferences

Select your preferred spoken language.

For best results, select the language you speak.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'

const spokenLanguages = [
  { label: 'English', value: 'en' },
  { label: 'Spanish', value: 'es' },
  { label: 'French', value: 'fr' },
  { label: 'German', value: 'de' },
  { label: 'Italian', value: 'it' },
  { label: 'Chinese', value: 'zh' },
  { label: 'Japanese', value: 'ja' },
] as const

const FormSchema = v.object({
  language: v.pipe(
    v.string(),
    v.minLength(1, 'Please select your spoken language.'),
    v.check(
      value => value !== 'auto',
      'Auto-detection is not allowed. Please select a specific language.',
    ),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    language: '',
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-lg">
    <CardHeader>
      <CardTitle>Language Preferences</CardTitle>
      <CardDescription>
        Select your preferred spoken language.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-select" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['language']">
            <Field
              orientation="responsive"
              :data-invalid="field.errors !== null"
            >
              <FieldContent>
                <FieldLabel for="form-formisch-select-language">
                  Spoken Language
                </FieldLabel>
                <FieldDescription>
                  For best results, select the language you speak.
                </FieldDescription>
                <FieldError
                  v-if="field.errors"
                  :errors="field.errors.map(message => ({ message }))"
                />
              </FieldContent>
              <Select v-model="field.input">
                <SelectTrigger
                  id="form-formisch-select-language"
                  :aria-invalid="field.errors !== null"
                  class="min-w-[120px]"
                >
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent position="item-aligned">
                  <SelectItem value="auto">
                    Auto
                  </SelectItem>
                  <SelectSeparator />
                  <SelectItem
                    v-for="language in spokenLanguages"
                    :key="language.value"
                    :value="language.value"
                  >
                    {{ language.label }}
                  </SelectItem>
                </SelectContent>
              </Select>
            </Field>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-select">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <FormischField :of="form" :path="['language']" v-slot="field">
    <Field orientation="responsive" :data-invalid="field.errors !== null">
      <FieldContent>
        <FieldLabel for="form-language">Spoken Language</FieldLabel>
        <FieldDescription>
          For best results, select the language you speak.
        </FieldDescription>
        <FieldError
          v-if="field.errors"
          :errors="field.errors.map((message) => ({ message }))"
        />
      </FieldContent>
      <Select v-model="field.input">
        <SelectTrigger
          id="form-language"
          :aria-invalid="field.errors !== null"
          class="min-w-[120px]"
        >
          <SelectValue placeholder="Select" />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          <SelectItem value="auto">Auto</SelectItem>
          <SelectItem value="en">English</SelectItem>
        </SelectContent>
      </Select>
    </Field>
  </FormischField>
</template>

Checkbox

  • For checkbox arrays, read field.input and write the updated array back to it from @update:model-value.
  • To show errors, add the :aria-invalid prop to the Checkbox component and the :data-invalid prop to the Field component.
  • Remember to add data-slot="checkbox-group" to the FieldGroup component for proper styling and spacing.

Notifications

Manage your notification preferences.

Responses

Get notified for requests that take time, like research or image generation.

Tasks

Get notified when tasks you've created have updates.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from '@/components/ui/field'

const tasks = [
  {
    id: 'push',
    label: 'Push notifications',
  },
  {
    id: 'email',
    label: 'Email notifications',
  },
] as const

const FormSchema = v.object({
  responses: v.boolean(),
  tasks: v.pipe(
    v.array(v.string()),
    v.minLength(1, 'Please select at least one notification type.'),
    v.check(
      value => value.every(task => tasks.some(t => t.id === task)),
      'Invalid notification type selected.',
    ),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    responses: true,
    tasks: [],
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Notifications</CardTitle>
      <CardDescription>Manage your notification preferences.</CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-checkbox" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['responses']">
            <div>
              <FieldSet :data-invalid="field.errors !== null">
                <FieldLegend variant="label">
                  Responses
                </FieldLegend>
                <FieldDescription>
                  Get notified for requests that take time, like research or
                  image generation.
                </FieldDescription>
                <FieldGroup data-slot="checkbox-group">
                  <Field orientation="horizontal">
                    <Checkbox
                      id="form-formisch-checkbox-responses"
                      :model-value="field.input ?? false"
                      disabled
                      @update:model-value="(checked) => field.input = checked === true"
                    />
                    <FieldLabel
                      for="form-formisch-checkbox-responses"
                      class="font-normal"
                    >
                      Push notifications
                    </FieldLabel>
                  </Field>
                </FieldGroup>
              </FieldSet>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </div>
          </FormischField>
          <FieldSeparator />
          <FormischField v-slot="field" :of="form" :path="['tasks']">
            <FieldGroup>
              <FieldSet :data-invalid="field.errors !== null">
                <FieldLegend variant="label">
                  Tasks
                </FieldLegend>
                <FieldDescription>
                  Get notified when tasks you've created have updates.
                </FieldDescription>
                <FieldGroup data-slot="checkbox-group">
                  <Field
                    v-for="task in tasks"
                    :key="task.id"
                    orientation="horizontal"
                    :data-invalid="field.errors !== null"
                  >
                    <Checkbox
                      :id="`form-formisch-checkbox-${task.id}`"
                      :aria-invalid="field.errors !== null"
                      :model-value="(field.input ?? []).includes(task.id)"
                      @update:model-value="(checked) => {
                        field.input = checked === true
                          ? [...(field.input ?? []), task.id]
                          : (field.input ?? []).filter(value => value !== task.id)
                      }"
                    />
                    <FieldLabel
                      :for="`form-formisch-checkbox-${task.id}`"
                      class="font-normal"
                    >
                      {{ task.label }}
                    </FieldLabel>
                  </Field>
                </FieldGroup>
              </FieldSet>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </FieldGroup>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-checkbox">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <FormischField :of="form" :path="['tasks']" v-slot="field">
    <FieldSet>
      <FieldLegend variant="label">Tasks</FieldLegend>
      <FieldDescription>
        Get notified when tasks you've created have updates.
      </FieldDescription>
      <FieldGroup data-slot="checkbox-group">
        <Field
          v-for="task in tasks"
          :key="task.id"
          orientation="horizontal"
          :data-invalid="field.errors !== null"
        >
          <Checkbox
            :id="`form-checkbox-${task.id}`"
            :aria-invalid="field.errors !== null"
            :model-value="field.input?.includes(task.id) ?? false"
            @update:model-value="
              (checked) =>
                (field.input = checked
                  ? [...(field.input ?? []), task.id]
                  : (field.input ?? []).filter((value) => value !== task.id))
            "
          />
          <FieldLabel :for="`form-checkbox-${task.id}`" class="font-normal">
            {{ task.label }}
          </FieldLabel>
        </Field>
      </FieldGroup>
      <FieldError
        v-if="field.errors"
        :errors="field.errors.map((message) => ({ message }))"
      />
    </FieldSet>
  </FormischField>
</template>

Radio Group

  • For radio groups, bind field.input with v-model on the RadioGroup component.
  • To show errors, add the :aria-invalid prop to the RadioGroupItem component and the :data-invalid prop to the Field component.

Subscription Plan

See pricing and features for each plan.

Plan

You can upgrade or downgrade your plan at any time.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
  FieldTitle,
} from '@/components/ui/field'
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/ui/radio-group'

const plans = [
  {
    id: 'starter',
    title: 'Starter (100K tokens/month)',
    description: 'For everyday use with basic features.',
  },
  {
    id: 'pro',
    title: 'Pro (1M tokens/month)',
    description: 'For advanced AI usage with more features.',
  },
  {
    id: 'enterprise',
    title: 'Enterprise (Unlimited tokens)',
    description: 'For large teams and heavy usage.',
  },
] as const

const FormSchema = v.object({
  plan: v.pipe(
    v.string(),
    v.minLength(1, 'You must select a subscription plan to continue.'),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    plan: '',
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Subscription Plan</CardTitle>
      <CardDescription>
        See pricing and features for each plan.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-radiogroup" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['plan']">
            <FieldSet :data-invalid="field.errors !== null">
              <FieldLegend>Plan</FieldLegend>
              <FieldDescription>
                You can upgrade or downgrade your plan at any time.
              </FieldDescription>
              <RadioGroup
                v-model="field.input"
                :aria-invalid="field.errors !== null"
              >
                <FieldLabel
                  v-for="plan in plans"
                  :key="plan.id"
                  :for="`form-formisch-radiogroup-${plan.id}`"
                >
                  <Field
                    orientation="horizontal"
                    :data-invalid="field.errors !== null"
                  >
                    <FieldContent>
                      <FieldTitle>{{ plan.title }}</FieldTitle>
                      <FieldDescription>
                        {{ plan.description }}
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      :id="`form-formisch-radiogroup-${plan.id}`"
                      :value="plan.id"
                      :aria-invalid="field.errors !== null"
                    />
                  </Field>
                </FieldLabel>
              </RadioGroup>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </FieldSet>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-radiogroup">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <FormischField :of="form" :path="['plan']" v-slot="field">
    <FieldSet>
      <FieldLegend>Plan</FieldLegend>
      <FieldDescription>
        You can upgrade or downgrade your plan at any time.
      </FieldDescription>
      <RadioGroup v-model="field.input">
        <FieldLabel
          v-for="plan in plans"
          :key="plan.id"
          :for="`form-radiogroup-${plan.id}`"
        >
          <Field orientation="horizontal" :data-invalid="field.errors !== null">
            <FieldContent>
              <FieldTitle>{{ plan.title }}</FieldTitle>
              <FieldDescription>{{ plan.description }}</FieldDescription>
            </FieldContent>
            <RadioGroupItem
              :value="plan.id"
              :id="`form-radiogroup-${plan.id}`"
              :aria-invalid="field.errors !== null"
            />
          </Field>
        </FieldLabel>
      </RadioGroup>
      <FieldError
        v-if="field.errors"
        :errors="field.errors.map((message) => ({ message }))"
      />
    </FieldSet>
  </FormischField>
</template>

Switch

  • For switches, bind field.input with v-model on the Switch component.
  • To show errors, add the :aria-invalid prop to the Switch component and the :data-invalid prop to the Field component.

Security Settings

Manage your account security preferences.

Enable multi-factor authentication to secure your account.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Switch } from '@/components/ui/switch'

const FormSchema = v.object({
  twoFactor: v.pipe(
    v.boolean(),
    v.check(
      value => value === true,
      'It is highly recommended to enable two-factor authentication.',
    ),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    twoFactor: false,
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Security Settings</CardTitle>
      <CardDescription>
        Manage your account security preferences.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-switch" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['twoFactor']">
            <Field
              orientation="horizontal"
              :data-invalid="field.errors !== null"
            >
              <FieldContent>
                <FieldLabel for="form-formisch-switch-twoFactor">
                  Multi-factor authentication
                </FieldLabel>
                <FieldDescription>
                  Enable multi-factor authentication to secure your account.
                </FieldDescription>
                <FieldError
                  v-if="field.errors"
                  :errors="field.errors.map(message => ({ message }))"
                />
              </FieldContent>
              <Switch
                id="form-formisch-switch-twoFactor"
                v-model="field.input"
                :aria-invalid="field.errors !== null"
              />
            </Field>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-switch">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
<template>
  <FormischField :of="form" :path="['twoFactor']" v-slot="field">
    <Field orientation="horizontal" :data-invalid="field.errors !== null">
      <FieldContent>
        <FieldLabel for="form-twoFactor">
          Multi-factor authentication
        </FieldLabel>
        <FieldDescription>
          Enable multi-factor authentication to secure your account.
        </FieldDescription>
        <FieldError
          v-if="field.errors"
          :errors="field.errors.map((message) => ({ message }))"
        />
      </FieldContent>
      <Switch
        id="form-twoFactor"
        v-model="field.input"
        :aria-invalid="field.errors !== null"
      />
    </Field>
  </FormischField>
</template>

Complex Forms

Here is an example of a more complex form with multiple fields and validation.

You're almost there!

Choose your subscription plan and billing period.

Subscription Plan

Choose your subscription plan.

Choose how often you want to be billed.

Add-ons

Select additional features you'd like to include.

Advanced analytics and reporting

Automated daily backups

24/7 premium customer support

Receive email updates about your subscription

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import { Form, Field as FormischField, reset, useForm } from '@formisch/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldTitle,
} from '@/components/ui/field'
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/ui/radio-group'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'

const addons = [
  {
    id: 'analytics',
    title: 'Analytics',
    description: 'Advanced analytics and reporting',
  },
  {
    id: 'backup',
    title: 'Backup',
    description: 'Automated daily backups',
  },
  {
    id: 'support',
    title: 'Priority Support',
    description: '24/7 premium customer support',
  },
] as const

const FormSchema = v.object({
  plan: v.pipe(
    v.string(),
    v.minLength(1, 'Please select a subscription plan'),
    v.check(
      value => value === 'basic' || value === 'pro',
      'Invalid plan selection. Please choose Basic or Pro',
    ),
  ),
  billingPeriod: v.pipe(
    v.string(),
    v.minLength(1, 'Please select a billing period'),
  ),
  addons: v.pipe(
    v.array(v.string()),
    v.minLength(1, 'Please select at least one add-on'),
    v.maxLength(3, 'You can select up to 3 add-ons'),
    v.check(
      value => value.every(addon => addons.some(a => a.id === addon)),
      'You selected an invalid add-on',
    ),
  ),
  emailNotifications: v.boolean(),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    plan: 'basic',
    billingPeriod: '',
    addons: [],
    emailNotifications: false,
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full max-w-sm">
    <CardHeader class="border-b">
      <CardTitle>You're almost there!</CardTitle>
      <CardDescription>
        Choose your subscription plan and billing period.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-complex" :of="form" @submit="handleSubmit">
        <FieldGroup>
          <FormischField v-slot="field" :of="form" :path="['plan']">
            <FieldSet :data-invalid="field.errors !== null">
              <FieldLegend variant="label">
                Subscription Plan
              </FieldLegend>
              <FieldDescription>
                Choose your subscription plan.
              </FieldDescription>
              <RadioGroup
                v-model="field.input"
                :aria-invalid="field.errors !== null"
              >
                <FieldLabel for="form-formisch-complex-basic">
                  <Field orientation="horizontal">
                    <FieldContent>
                      <FieldTitle>Basic</FieldTitle>
                      <FieldDescription>
                        For individuals and small teams
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      id="form-formisch-complex-basic"
                      value="basic"
                    />
                  </Field>
                </FieldLabel>
                <FieldLabel for="form-formisch-complex-pro">
                  <Field orientation="horizontal">
                    <FieldContent>
                      <FieldTitle>Pro</FieldTitle>
                      <FieldDescription>
                        For businesses with higher demands
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      id="form-formisch-complex-pro"
                      value="pro"
                    />
                  </Field>
                </FieldLabel>
              </RadioGroup>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </FieldSet>
          </FormischField>
          <FieldSeparator />
          <FormischField v-slot="field" :of="form" :path="['billingPeriod']">
            <Field :data-invalid="field.errors !== null">
              <FieldLabel for="form-formisch-complex-billingPeriod">
                Billing Period
              </FieldLabel>
              <Select v-model="field.input">
                <SelectTrigger
                  id="form-formisch-complex-billingPeriod"
                  :aria-invalid="field.errors !== null"
                >
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="monthly">
                    Monthly
                  </SelectItem>
                  <SelectItem value="yearly">
                    Yearly
                  </SelectItem>
                </SelectContent>
              </Select>
              <FieldDescription>
                Choose how often you want to be billed.
              </FieldDescription>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>
          <FieldSeparator />
          <FormischField v-slot="field" :of="form" :path="['addons']">
            <FieldSet>
              <FieldLegend>Add-ons</FieldLegend>
              <FieldDescription>
                Select additional features you'd like to include.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                <Field
                  v-for="addon in addons"
                  :key="addon.id"
                  orientation="horizontal"
                  :data-invalid="field.errors !== null"
                >
                  <Checkbox
                    :id="`form-formisch-complex-${addon.id}`"
                    :aria-invalid="field.errors !== null"
                    :model-value="(field.input ?? []).includes(addon.id)"
                    @update:model-value="(checked) => {
                      field.input = checked === true
                        ? [...(field.input ?? []), addon.id]
                        : (field.input ?? []).filter(value => value !== addon.id)
                    }"
                  />
                  <FieldContent>
                    <FieldLabel :for="`form-formisch-complex-${addon.id}`">
                      {{ addon.title }}
                    </FieldLabel>
                    <FieldDescription>
                      {{ addon.description }}
                    </FieldDescription>
                  </FieldContent>
                </Field>
              </FieldGroup>
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </FieldSet>
          </FormischField>
          <FieldSeparator />
          <FormischField
            v-slot="field"
            :of="form"
            :path="['emailNotifications']"
          >
            <Field
              orientation="horizontal"
              :data-invalid="field.errors !== null"
            >
              <FieldContent>
                <FieldLabel for="form-formisch-complex-emailNotifications">
                  Email Notifications
                </FieldLabel>
                <FieldDescription>
                  Receive email updates about your subscription
                </FieldDescription>
              </FieldContent>
              <Switch
                id="form-formisch-complex-emailNotifications"
                v-model="field.input"
                :aria-invalid="field.errors !== null"
              />
              <FieldError
                v-if="field.errors"
                :errors="field.errors.map(message => ({ message }))"
              />
            </Field>
          </FormischField>
        </FieldGroup>
      </Form>
    </CardContent>
    <CardFooter class="border-t">
      <Field>
        <Button type="submit" form="form-formisch-complex">
          Save Preferences
        </Button>
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Resetting the Form

Formisch exposes a top-level reset function. Pass the form store to reset it to its initial input.

<template>
  <Button type="button" variant="outline" @click="reset(form)">
    Reset
  </Button>
</template>

You can also reset to new initial values, or reset while keeping the user's current input:

// Reset to a fresh set of initial values
reset(form, { initialInput: { title: '', description: '' } })

// Sync the baseline to new server data, but keep the user's edits
reset(form, { initialInput: serverData, keepInput: true })

Array Fields

Formisch provides a FieldArray component and a set of helper functions for managing dynamic array fields. Use it whenever you need to add, remove, or reorder items.

Contact Emails

Manage your contact email addresses.

Email Addresses

Add up to 5 email addresses where we can contact you.

<script setup lang="ts">
import type { SubmitHandler } from '@formisch/vue'
import {
  FieldArray,
  Form,
  Field as FormischField,
  insert,
  remove,
  reset,
  useForm,
} from '@formisch/vue'
import { XIcon } from '@lucide/vue'
import * as v from 'valibot'
import { toast } from 'vue-sonner'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLegend,
  FieldSet,
} from '@/components/ui/field'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/components/ui/input-group'

const FormSchema = v.object({
  emails: v.pipe(
    v.array(
      v.object({
        address: v.pipe(
          v.string(),
          v.nonEmpty('Enter an email address.'),
          v.email('Enter a valid email address.'),
        ),
      }),
    ),
    v.minLength(1, 'Add at least one email address.'),
    v.maxLength(5, 'You can add up to 5 email addresses.'),
  ),
})

const form = useForm({
  schema: FormSchema,
  initialInput: {
    emails: [{ address: '' }, { address: '' }],
  },
})

const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(output, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
}
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader class="border-b">
      <CardTitle>Contact Emails</CardTitle>
      <CardDescription>Manage your contact email addresses.</CardDescription>
    </CardHeader>
    <CardContent>
      <Form id="form-formisch-array" :of="form" @submit="handleSubmit">
        <FieldArray v-slot="fieldArray" :of="form" :path="['emails']">
          <FieldSet class="gap-4">
            <FieldLegend variant="label">
              Email Addresses
            </FieldLegend>
            <FieldDescription>
              Add up to 5 email addresses where we can contact you.
            </FieldDescription>
            <FieldGroup class="gap-4">
              <FormischField
                v-for="(item, index) in fieldArray.items"
                :key="item"
                v-slot="field"
                :of="form"
                :path="['emails', index, 'address']"
              >
                <Field
                  orientation="horizontal"
                  :data-invalid="field.errors !== null"
                >
                  <FieldContent>
                    <InputGroup>
                      <InputGroupInput
                        :id="`form-formisch-array-email-${index}`"
                        v-model="field.input"
                        v-bind="field.props"
                        :aria-invalid="field.errors !== null"
                        placeholder="name@example.com"
                        type="email"
                        autocomplete="email"
                      />
                      <InputGroupAddon
                        v-if="fieldArray.items.length > 1"
                        align="inline-end"
                      >
                        <InputGroupButton
                          type="button"
                          variant="ghost"
                          size="icon-xs"
                          :aria-label="`Remove email ${index + 1}`"
                          @click="remove(form, { path: ['emails'], at: index })"
                        >
                          <XIcon />
                        </InputGroupButton>
                      </InputGroupAddon>
                    </InputGroup>
                    <FieldError
                      v-if="field.errors"
                      :errors="field.errors.map(message => ({ message }))"
                    />
                  </FieldContent>
                </Field>
              </FormischField>
              <Button
                type="button"
                variant="outline"
                size="sm"
                :disabled="fieldArray.items.length >= 5"
                @click="insert(form, { path: ['emails'], initialInput: { address: '' } })"
              >
                Add Email Address
              </Button>
            </FieldGroup>
            <FieldError
              v-if="fieldArray.errors"
              :errors="fieldArray.errors.map(message => ({ message }))"
            />
          </FieldSet>
        </FieldArray>
      </Form>
    </CardContent>
    <CardFooter class="border-t">
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="reset(form)">
          Reset
        </Button>
        <Button type="submit" form="form-formisch-array">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Using FieldArray

FieldArray follows the same slot pattern as Field. Its items array contains a stable key per item that you should use as the v-for key.

<script setup lang="ts">
import { Field as FormischField, FieldArray, insert, remove } from '@formisch/vue'
</script>

<template>
  <FieldArray :of="form" :path="['emails']" v-slot="fieldArray">
    <FieldGroup class="gap-4">
      <FormischField
        v-for="(item, index) in fieldArray.items"
        :key="item"
        :of="form"
        :path="['emails', index, 'address']"
        v-slot="field"
      >
        <!-- ... -->
      </FormischField>
    </FieldGroup>
  </FieldArray>
</template>

Array Field Structure

Wrap your array fields in a FieldSet with a FieldLegend and FieldDescription.

<template>
  <FieldSet class="gap-4">
    <FieldLegend variant="label">Email Addresses</FieldLegend>
    <FieldDescription>
      Add up to 5 email addresses where we can contact you.
    </FieldDescription>
    <FieldGroup class="gap-4">
      <!-- Array items go here -->
    </FieldGroup>
  </FieldSet>
</template>

Adding Items

Use the insert function to add new items to the array. By default new items are appended to the end. You can also pass an at index to insert at a specific position.

<template>
  <Button
    type="button"
    variant="outline"
    size="sm"
    @click="insert(form, { path: ['emails'], initialInput: { address: '' } })"
    :disabled="fieldArray.items.length >= 5"
  >
    Add Email Address
  </Button>
</template>

Removing Items

Use the remove function with an at index to remove items from the array.

<template>
  <InputGroupAddon v-if="fieldArray.items.length > 1" align="inline-end">
    <InputGroupButton
      type="button"
      variant="ghost"
      size="icon-xs"
      @click="remove(form, { path: ['emails'], at: index })"
      :aria-label="`Remove email ${index + 1}`"
    >
      <XIcon />
    </InputGroupButton>
  </InputGroupAddon>
</template>

Formisch also exposes move, swap, and replace for reordering and replacing items. They follow the same (form, config) signature.

Array Validation

Use Valibot's array and pipeline validators to constrain array fields.

<script setup lang="ts">
const FormSchema = v.object({
  emails: v.pipe(
    v.array(
      v.object({
        address: v.pipe(
          v.string(),
          v.nonEmpty('Enter an email address.'),
          v.email('Enter a valid email address.'),
        ),
      }),
    ),
    v.minLength(1, 'Add at least one email address.'),
    v.maxLength(5, 'You can add up to 5 email addresses.'),
  ),
})
</script>