Skip to content

Async Patterns

Handle asynchronous operations like loading form data from APIs, async validation, and server error integration.

Async Default Values

Load initial form values from an API or database:

vue
<script setup>
import { useForm } from '@vuehookform/core'

const { register, handleSubmit, formState } = useForm({
  schema,
  defaultValues: async () => {
    const response = await fetch('/api/user/profile')
    return response.json()
  },
})
</script>

<template>
  <!-- Show loading state while fetching -->
  <div v-if="formState.value.isLoading" class="loading">Loading form data...</div>

  <form v-else @submit="handleSubmit(onSubmit)">
    <input v-bind="register('name')" />
    <input v-bind="register('email')" />
    <button type="submit">Save</button>
  </form>
</template>

Loading States

The form provides two properties to track async loading:

PropertyTypeDescription
isLoadingbooleantrue while fetching async default values
isReadybooleantrue once initialization is complete (inverse of isLoading)
vue
<script setup>
const { formState } = useForm({
  schema,
  defaultValues: async () => fetchUserData(),
})

// Use either property based on your preference
const loading = computed(() => formState.value.isLoading)
const ready = computed(() => formState.value.isReady)
</script>

<template>
  <div v-if="!formState.value.isReady">
    <Spinner />
  </div>
  <form v-else>...</form>
</template>

Error Handling

Handle errors that occur during async default value loading:

typescript
const { formState } = useForm({
  schema,
  defaultValues: async () => {
    const response = await fetch('/api/user/profile')
    if (!response.ok) {
      throw new Error('Failed to load profile')
    }
    return response.json()
  },
  onDefaultValuesError: (error) => {
    console.error('Failed to load form data:', error)
    // Show notification, redirect, etc.
    toast.error('Could not load your profile data')
  },
})

Access the error in your template via formState.value.defaultValuesError:

vue
<template>
  <div v-if="formState.value.isLoading">Loading...</div>

  <div v-else-if="formState.value.defaultValuesError" class="error">
    Failed to load form data.
    <button @click="retryLoad">Retry</button>
  </div>

  <form v-else @submit="handleSubmit(onSubmit)">
    <!-- form fields -->
  </form>
</template>

Async Field Validation

Validate fields asynchronously (e.g., checking username availability):

vue
<script setup>
import { useForm } from '@vuehookform/core'

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

// Custom async validator
const usernameBindings = register('username', {
  validate: async (value) => {
    if (!value || value.length < 3) return undefined

    const response = await fetch(`/api/check-username?name=${value}`)
    const { available } = await response.json()

    return available ? undefined : 'Username is already taken'
  },
})
</script>

<template>
  <div>
    <input v-bind="usernameBindings" />
    <span v-if="formState.value.validatingFields.has('username')"> Checking availability... </span>
    <span v-else-if="formState.value.errors.username" class="error">
      {{ formState.value.errors.username }}
    </span>
  </div>
</template>

Debouncing Async Validation

Prevent excessive API calls during typing with validateDebounce:

typescript
const usernameBindings = register('username', {
  validate: async (value) => {
    const response = await fetch(`/api/check-username?name=${value}`)
    const { available } = await response.json()
    return available ? undefined : 'Username is already taken'
  },
  validateDebounce: 500, // Wait 500ms after user stops typing
})

Validation Loading States

Track which fields are currently validating:

PropertyTypeDescription
isValidatingbooleantrue if any field is currently validating
validatingFieldsSet<string>Set of field names currently validating
vue
<script setup>
const { register, formState } = useForm({ schema })
</script>

<template>
  <form>
    <div>
      <input v-bind="register('username', { validate: checkUsername, validateDebounce: 300 })" />
      <Spinner v-if="formState.value.validatingFields.has('username')" />
    </div>

    <div>
      <input v-bind="register('email', { validate: checkEmail, validateDebounce: 300 })" />
      <Spinner v-if="formState.value.validatingFields.has('email')" />
    </div>

    <!-- Disable submit while any validation is pending -->
    <button type="submit" :disabled="formState.value.isValidating">
      {{ formState.value.isValidating ? 'Validating...' : 'Submit' }}
    </button>
  </form>
</template>

Dependent Field Validation

Re-validate related fields when a field changes using deps:

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

// When password changes, re-validate confirmPassword
const passwordBindings = register('password', {
  deps: ['confirmPassword'],
})

const confirmBindings = register('confirmPassword', {
  validate: (value, formValues) => {
    if (value !== formValues.password) {
      return 'Passwords do not match'
    }
    return undefined
  },
})

Server Error Integration

External Errors Option

Pass server-side validation errors into the form:

vue
<script setup>
import { ref } from 'vue'
import { useForm } from '@vuehookform/core'

const serverErrors = ref({})

const { register, handleSubmit, formState } = useForm({
  schema,
  errors: serverErrors, // Merge with validation errors
})

async function onSubmit(data) {
  try {
    await api.saveUser(data)
  } catch (error) {
    if (error.validationErrors) {
      // Map server errors to form fields
      serverErrors.value = error.validationErrors
    }
  }
}
</script>

Server errors take precedence over client-side validation errors when both exist for the same field.

Setting Errors After Submission

Alternatively, use setError or setErrors to add server errors:

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

async function onSubmit(data) {
  try {
    await api.saveUser(data)
  } catch (error) {
    if (error.field) {
      // Single field error
      setError(error.field, { message: error.message })
    } else if (error.errors) {
      // Multiple field errors
      setErrors(error.errors)
    } else {
      // Form-level error
      setError('root', { message: 'Submission failed. Please try again.' })
    }
  }
}

External Values Sync

Sync form values with external state (e.g., parent component, store, or URL params):

vue
<script setup>
import { computed } from 'vue'
import { useForm } from '@vuehookform/core'
import { useRoute } from 'vue-router'

const route = useRoute()

// Sync form with URL query params
const externalValues = computed(() => ({
  search: route.query.q || '',
  category: route.query.cat || 'all',
}))

const { register } = useForm({
  schema,
  values: externalValues, // Form syncs when this changes
})
</script>

Key behaviors of values:

  • Changes to values update form fields without marking them as dirty
  • Useful for server-fetched data that shouldn't trigger "unsaved changes" warnings
  • Can be a plain object or a reactive ref/computed

values vs defaultValues

FeaturedefaultValuesvalues
Sets initial valuesYesYes
Marks fields as dirtyNo (they match defaults)No
Async loading supportYes (via async function)No (use reactive ref)
Re-syncs on changeNo (only on reset)Yes (automatically)

Use defaultValues for initial form state. Use values when you need continuous sync with external state.

Complete Example

A user profile form with async loading, async validation, and server error handling:

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

const schema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters'),
  email: z.string().email('Invalid email address'),
  bio: z.string().max(500).optional(),
})

const serverErrors = ref({})

const { register, handleSubmit, formState, setError } = useForm({
  schema,
  defaultValues: async () => {
    const response = await fetch('/api/profile')
    if (!response.ok) throw new Error('Failed to load profile')
    return response.json()
  },
  onDefaultValuesError: (error) => {
    console.error('Load failed:', error)
  },
  errors: serverErrors,
  mode: 'onBlur',
})

const usernameBindings = register('username', {
  validate: async (value) => {
    if (!value || value.length < 3) return undefined
    const res = await fetch(`/api/check-username?name=${value}`)
    const { available } = await res.json()
    return available ? undefined : 'Username is taken'
  },
  validateDebounce: 500,
})

async function onSubmit(data) {
  try {
    await fetch('/api/profile', {
      method: 'PUT',
      body: JSON.stringify(data),
    })
  } catch (error) {
    setError('root', { message: 'Failed to save profile' })
  }
}
</script>

<template>
  <!-- Loading state -->
  <div v-if="formState.value.isLoading" class="loading"><Spinner /> Loading your profile...</div>

  <!-- Load error state -->
  <div v-else-if="formState.value.defaultValuesError" class="error">
    <p>Could not load your profile.</p>
    <button @click="() => location.reload()">Retry</button>
  </div>

  <!-- Form ready -->
  <form v-else @submit="handleSubmit(onSubmit)">
    <!-- Root-level error -->
    <div v-if="formState.value.errors.root" class="form-error">
      {{ formState.value.errors.root.message }}
    </div>

    <div class="field">
      <label>Username</label>
      <input v-bind="usernameBindings" />
      <span v-if="formState.value.validatingFields.has('username')" class="hint">
        Checking availability...
      </span>
      <span v-else-if="formState.value.errors.username" class="error">
        {{ formState.value.errors.username }}
      </span>
    </div>

    <div class="field">
      <label>Email</label>
      <input 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>Bio</label>
      <textarea v-bind="register('bio')"></textarea>
    </div>

    <button type="submit" :disabled="formState.value.isSubmitting || formState.value.isValidating">
      {{ formState.value.isSubmitting ? 'Saving...' : 'Save Profile' }}
    </button>
  </form>
</template>

Next Steps

Released under the MIT License.