Skip to content

Submission

Handle form submission with validation, loading states, and error handling.

Basic Submission

Use handleSubmit to wrap your submission handler:

vue
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'

const schema = z.object({
  email: z.email(),
  message: z.string().min(10),
})

const { register, handleSubmit } = useForm({ schema })

const onSubmit = (data: z.infer<typeof schema>) => {
  console.log('Valid data:', data)
  // data is fully typed: { email: string, message: string }
}
</script>

<template>
  <form @submit="handleSubmit(onSubmit)">
    <input v-bind="register('email')" type="email" />
    <textarea v-bind="register('message')"></textarea>
    <button type="submit">Send</button>
  </form>
</template>

How handleSubmit Works

  1. Prevents the default form submission
  2. Syncs values from uncontrolled inputs
  3. Validates all fields against the schema
  4. If valid, calls your handler with a deep clone of the typed data (safe to mutate)
  5. If invalid, populates formState.value.errors and auto-focuses first error field
  6. Manages isSubmitting state automatically
  7. Sets isSubmitSuccessful after successful submission

Auto-Focus on Error

By default, the first field with an error is focused after validation fails:

typescript
const { handleSubmit } = useForm({
  schema,
  shouldFocusError: true, // Default: true
})

Disable this behavior:

typescript
const { handleSubmit } = useForm({
  schema,
  shouldFocusError: false, // Don't auto-focus errors
})

Async Submission

Handle async operations like API calls:

typescript
const onSubmit = async (data: z.infer<typeof schema>) => {
  // formState.value.isSubmitting is true during this
  const response = await fetch('/api/submit', {
    method: 'POST',
    body: JSON.stringify(data),
  })

  if (!response.ok) {
    throw new Error('Submission failed')
  }

  // formState.value.isSubmitSuccessful is true after this
}
vue
<template>
  <form @submit="handleSubmit(onSubmit)">
    <!-- fields -->
    <button type="submit" :disabled="formState.value.isSubmitting">
      {{ formState.value.isSubmitting ? 'Sending...' : 'Send' }}
    </button>
  </form>
</template>

Error Handling

Validation Errors

Validation errors are automatically populated:

vue
<template>
  <form @submit="handleSubmit(onSubmit)">
    <input v-bind="register('email')" />
    <span v-if="formState.value.errors.email">
      {{ formState.value.errors.email }}
    </span>
    <button type="submit">Submit</button>
  </form>
</template>

Server Errors

Handle server-side errors manually:

vue
<script setup>
import { ref } from 'vue'

const serverError = (ref < string) | (null > null)

const onSubmit = async (data) => {
  serverError.value = null

  try {
    const response = await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(data),
    })

    if (!response.ok) {
      const error = await response.json()
      serverError.value = error.message
      return
    }

    // Success handling
  } catch (error) {
    serverError.value = 'An unexpected error occurred'
  }
}
</script>

<template>
  <form @submit="handleSubmit(onSubmit)">
    <div v-if="serverError" class="error-banner">
      {{ serverError }}
    </div>
    <!-- fields -->
  </form>
</template>

Server Validation Errors

Map server validation errors back to fields:

typescript
const { setError } = useForm({ schema })

const onSubmit = async (data) => {
  const response = await fetch('/api/submit', {
    method: 'POST',
    body: JSON.stringify(data),
  })

  if (!response.ok) {
    const errors = await response.json()

    // Set field-specific errors from server
    if (errors.email) {
      setError('email', errors.email)
    }
    if (errors.username) {
      setError('username', errors.username)
    }
    return
  }
}

Invalid Submission Handler

Handle cases where validation fails:

typescript
const onSubmit = (data) => {
  // Only called if valid
  console.log('Valid:', data)
}

const onInvalid = (errors) => {
  // Called when validation fails
  console.log('Errors:', errors)
}

// In template
handleSubmit(onSubmit, onInvalid)
vue
<template>
  <form @submit="handleSubmit(onSubmit, onInvalid)">
    <!-- fields -->
  </form>
</template>

Reset After Submission

Clear the form after successful submission:

typescript
const { handleSubmit, reset } = useForm({ schema })

const onSubmit = async (data) => {
  await submitData(data)
  reset() // Clear form
}

Or reset with new values:

typescript
const onSubmit = async (data) => {
  const result = await submitData(data)
  reset({
    id: result.id,
    // Pre-populate with server response
  })
}

Preventing Double Submission

The isSubmitting state prevents accidental double submissions:

vue
<template>
  <button
    type="submit"
    :disabled="formState.value.isSubmitting"
    :class="{ 'opacity-50': formState.value.isSubmitting }"
  >
    <span v-if="formState.value.isSubmitting">
      <LoadingSpinner />
      Processing...
    </span>
    <span v-else>Submit</span>
  </button>
</template>

Complete Example

vue
<script setup lang="ts">
import { ref } from 'vue'
import { useForm } from '@vuehookform/core'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(2),
  email: z.email(),
  message: z.string().min(10),
})

const serverError = ref<string | null>(null)
const submitted = ref(false)

const { register, handleSubmit, formState, reset } = useForm({
  schema,
  mode: 'onBlur',
})

const onSubmit = async (data: z.infer<typeof schema>) => {
  serverError.value = null

  try {
    await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    })

    submitted.value = true
    reset()
  } catch (error) {
    serverError.value = 'Failed to send message. Please try again.'
  }
}

const onInvalid = () => {
  // Scroll to first error
  const firstError = document.querySelector('.error')
  firstError?.scrollIntoView({ behavior: 'smooth' })
}
</script>

<template>
  <div v-if="submitted" class="success">
    <h2>Thank you!</h2>
    <p>Your message has been sent.</p>
    <button @click="submitted = false">Send another</button>
  </div>

  <form v-else @submit="handleSubmit(onSubmit, onInvalid)">
    <div v-if="serverError" class="error-banner">
      {{ serverError }}
    </div>

    <div class="field">
      <label for="name">Name</label>
      <input id="name" v-bind="register('name')" />
      <span v-if="formState.value.errors.name" class="error">
        {{ formState.value.errors.name }}
      </span>
    </div>

    <div class="field">
      <label for="email">Email</label>
      <input id="email" v-bind="register('email')" type="email" />
      <span v-if="formState.value.errors.email" class="error">
        {{ formState.value.errors.email }}
      </span>
    </div>

    <div class="field">
      <label for="message">Message</label>
      <textarea id="message" v-bind="register('message')" rows="4"></textarea>
      <span v-if="formState.value.errors.message" class="error">
        {{ formState.value.errors.message }}
      </span>
    </div>

    <button type="submit" :disabled="formState.value.isSubmitting">
      {{ formState.value.isSubmitting ? 'Sending...' : 'Send Message' }}
    </button>
  </form>
</template>

Next Steps

Released under the MIT License.