Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

In Vue 3, validate a form by keeping its values in reactive state, defining rules that return field errors, showing those errors at the right interaction point, and running the complete check again before submission. For small forms, plain Vue is enough; for nested data, schemas, asynchronous rules, or reusable components, Vuelidate and vee-validate provide the structure.

Use Vue 3 and a real HTML form

Vue 3 is the current baseline; Vue 2 support ended on December 31, 2023. Start with semantic HTML rather than replacing browser controls with click handlers. Use <form>, a <label> for every control, an <input>, <textarea> or <select> as appropriate, and a submit <button>. Match each label’s for attribute to the control’s id.

Vue’s v-model synchronizes these controls with JavaScript state. Text fields use the value property and input event; checkboxes and radios use checked and change; selects use value and change. This synchronization is not validation by itself: required fields, formats, cross-field rules, and server constraints still need explicit checks.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

What the modifiers do

  • v-model.lazy updates on change instead of every input.
  • v-model.number attempts to cast the value to a number.
  • v-model.trim removes surrounding whitespace.

Choose modifiers for data behavior, not as a substitute for a rule. A trimmed empty string can still be invalid, and numeric casting does not prove that a value is in range.

A complete hand-written Vue 3 validator

This example validates a name, email, password, and confirmation. Errors appear after a field is blurred or after the first submit attempt. The same rule functions run in both the display path and the final submit path, so pressing Enter cannot bypass validation.

<script setup>
import { computed, reactive, ref } from 'vue'

const values = reactive({
  name: '',
  email: '',
  password: '',
  confirmPassword: ''
})

const touched = reactive({
  name: false,
  email: false,
  password: false,
  confirmPassword: false
})
const submitted = ref(false)
const serverError = ref('')

const rules = {
  name(value) {
    if (!value.trim()) return 'Enter your name.'
    if (value.trim().length < 2) return 'Use at least 2 characters.'
    return ''
  },
  email(value) {
    if (!value.trim()) return 'Enter an email address.'
    return /^[^s@]+@[^s@]+.[^s@]+$/.test(value)
      ? ''
      : 'Enter a valid email address.'
  },
  password(value) {
    if (value.length < 8) return 'Use at least 8 characters.'
    return ''
  },
  confirmPassword(value) {
    if (value !== values.password) return 'Passwords do not match.'
    return ''
  }
}

const errors = computed(() => ({
  name: rules.name(values.name),
  email: rules.email(values.email),
  password: rules.password(values.password),
  confirmPassword: rules.confirmPassword(values.confirmPassword)
}))

const isInvalid = computed(() => Object.values(errors.value).some(Boolean))

function showError(field) {
  return (touched[field] || submitted.value) ? errors.value[field] : ''
}

function touch(field) {
  touched[field] = true
}

async function submit() {
  submitted.value = true
  serverError.value = ''
  Object.keys(touched).forEach(field => { touched[field] = true })

  if (isInvalid.value) return

  try {
    // Replace with your authenticated API request.
    await saveAccount({ ...values })
  } catch (error) {
    serverError.value = 'We could not save the form. Try again.'
  }
}

async function saveAccount(payload) {
  // Example placeholder; do not treat client validation as authorization.
  return payload
}
</script>

<template>
  <form novalidate @submit.prevent='submit'>
    <div>
      <label for='name'>Name</label>
      <input id='name' v-model.trim='values.name' name='name'
        autocomplete='name' :aria-invalid='!!showError("name")'
        aria-describedby='name-error' @blur='touch("name")' />
      <p v-if='showError("name")' id='name-error' role='alert'>{{ showError('name') }}</p>
    </div>

    <div>
      <label for='email'>Email</label>
      <input id='email' v-model.trim='values.email' name='email' type='email'
        autocomplete='email' :aria-invalid='!!showError("email")'
        aria-describedby='email-error' @blur='touch("email")' />
      <p v-if='showError("email")' id='email-error' role='alert'>{{ showError('email') }}</p>
    </div>

    <div>
      <label for='password'>Password</label>
      <input id='password' v-model='values.password' name='password' type='password'
        autocomplete='new-password' :aria-invalid='!!showError("password")'
        aria-describedby='password-error' @blur='touch("password")' />
      <p v-if='showError("password")' id='password-error' role='alert'>{{ showError('password') }}</p>
    </div>

    <div>
      <label for='confirm-password'>Confirm password</label>
      <input id='confirm-password' v-model='values.confirmPassword' type='password'
        autocomplete='new-password' :aria-invalid='!!showError("confirmPassword")'
        aria-describedby='confirm-password-error' @blur='touch("confirmPassword")' />
      <p v-if='showError("confirmPassword")' id='confirm-password-error' role='alert'>{{ showError('confirmPassword') }}</p>
    </div>

    <p v-if='serverError' role='alert'>{{ serverError }}</p>
    <button type='submit'>Create account</button>
  </form>
</template>

novalidate is optional. It is useful when you want one consistent error presentation, but remove it if you also want the browser’s built-in constraint UI. The API must validate the payload again, including authorization, uniqueness, and any security-sensitive rule.

Choose when errors become visible

On input

Validate on every change when feedback is immediate and inexpensive, such as a character counter or password length. Avoid showing an error while a user is still typing an email address or a multi-part value.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

On blur (touched)

Mark a field touched on blur and show its message afterward. This is a good default for most text fields because the user gets feedback after leaving the control without being interrupted on every keystroke.

On submit

On the first submit attempt, mark every field touched, render all errors, and move focus to the first invalid control. Keep a submit counter if your analytics or UI needs to distinguish repeated attempts.

Async rules

Debounce checks such as username availability, show a pending state, and ignore an older response if a newer value has already been entered. A failed network request should be presented as an availability or retry message, not silently treated as a valid value.

Vuelidate or vee-validate?

Both are Vue 3 choices, but they organize validation differently.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Concern Hand-written rules Vuelidate Next vee-validate v4
Rule style Functions you own Rules attached to a reactive model tree Field functions or form-level schemas
Best shape Small, mostly flat forms Nested models and repeatable collections Component-driven forms and schema boundaries
Timing and state You implement touched, pending, and submit state Model validation state from useVuelidate() Explicit valid, dirty, touched, pending, errors, and submit state
Schema and TypeScript Manual types and adapters Custom predicates and composed rules Yup, Zod, and Valibot integrations with typed adapters
Submission and reset Your submit and reset functions Your submit flow around the model Built-in controlled submission, reset, validation, and invalid-submit hooks
Custom components Manual event/value contracts Model binding you define useField and component APIs are designed for integration

Use Vuelidate for model-centric validation

Vuelidate is lightweight and decoupled. Its Vue 3 package, Vuelidate Next, is a natural fit when rules mirror a domain object, especially with nested models, collections, contextual validators, or custom predicates.

<script setup>
import { reactive } from 'vue'
import useVuelidate from '@vuelidate/core'
import { required, email, minLength, sameAs } from '@vuelidate/validators'

const state = reactive({ email: '', password: '', confirm: '' })
const rules = {
  email: { required, email },
  password: { required, minLength: minLength(8) },
  confirm: { sameAs: sameAs(() => state.password) }
}
const v$ = useVuelidate(rules, state)

async function submit() {
  const valid = await v$.value.$validate()
  if (!valid) return
  // Send state to the server.
}
</script>

<template>
  <form @submit.prevent='submit'>
    <input v-model='state.email' @blur='v$.email.$touch()' />
    <p v-if='v$.email.$error'>Enter a valid email.</p>
    <button>Save</button>
  </form>
</template>

Use the validation object’s dirty and error state to decide when to display messages; do not expose every rule failure before the user interacts with the field.

Use vee-validate for field and schema workflows

vee-validate v4 uses Vue 3 Composition API primitives such as useForm and useField. Its Form, Field, and ErrorMessage components provide a template-oriented alternative. It supports synchronous and asynchronous validation at field or form level, including Yup, Zod, and Valibot schemas.

<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  email: yup.string().required('Email is required').email('Enter a valid email'),
  password: yup.string().required('Password is required').min(8, 'Use at least 8 characters')
})

function onSubmit(values, actions) {
  // Values passed here already passed schema validation.
  return fetch('/api/account', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(values)
  }).catch(() => {
    actions.setFieldError('email', 'The server could not save this address.')
  })
}
</script>

<template>
  <Form :validation-schema='schema' @submit='onSubmit'>
    <label for='v-email'>Email</label>
    <Field id='v-email' name='email' type='email' />
    <ErrorMessage name='email' as='p' role='alert' />

    <label for='v-password'>Password</label>
    <Field id='v-password' name='password' type='password' />
    <ErrorMessage name='password' as='p' role='alert' />

    <button type='submit'>Create account</button>
  </Form>
</template>

vee-validate validates before the submit handler runs. Invalid submissions are stopped by default. Use handleSubmit for JavaScript or AJAX submission, submitForm when you want native submission preserved while blocking invalid data, and validate when you need to run checks without submitting. Its submission API also exposes invalid-submit events, pending and touched transitions, submit counts, and reset methods.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Typed schemas and reactive rules

Keep a schema at the form boundary so the same contract is visible to the UI and the request layer. vee-validate’s typed adapters include @vee-validate/yup and @vee-validate/zod; toTypedSchema can infer input and output types. Zod can be used as a drop-in alternative through @vee-validate/zod.

Do not recreate a large schema on every render. Define it once when rules are static. If a rule depends on reactive state, either use the library’s supported reactive schema mechanism or keep the dependent predicate small and explicit. The server remains authoritative when its constraints differ from the client schema.

Submission, server errors, and reset behavior

  1. Initialize values, including empty values for optional fields.
  2. Run field checks on blur or the interaction policy you selected.
  3. On submit, mark all fields touched and validate the complete model.
  4. Focus the first invalid control and ensure its message is announced.
  5. Send only validated client data, then process the server response.
  6. Map field-specific server errors back to the corresponding control; keep a separate form-level message for errors that do not belong to one field.
  7. Reset values, touched state, submit count, and server errors together after a successful save or an explicit reset action.

Never use a client result to authorize an operation, enforce uniqueness, or protect an endpoint. A user can bypass JavaScript entirely.

Accessible error messaging

  • Keep the visible label associated with the control through matching for/id values.
  • Give each error a stable id and connect it with aria-describedby.
  • Set aria-invalid='true' only when the field currently has an error.
  • Use role='alert' for concise, dynamic messages; avoid placing an entire changing form inside one live region.
  • Do not rely on red color alone. Preserve text and a clear focus outline.
  • When submit reveals several errors, move focus to the first invalid control and leave the rest in document order.
  • Use appropriate input types and autocomplete tokens so browsers and assistive technology understand the control.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshooting common failures

The form submits despite visible errors

Make the submit handler the form’s @submit.prevent handler and return immediately when the complete error collection is non-empty. With vee-validate, submit through handleSubmit or the Form component rather than a separate click handler.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

An error appears before the user types

Your template is rendering the rule result directly. Gate it behind a touched flag or submit state, or use the library’s dirty/touched metadata.

Cross-field confirmation is stale

Compute the confirmation rule from the current password value rather than capturing the initial value. In a library, use a reactive predicate or schema reference.

Async checks overwrite newer results

Debounce requests and associate each response with the value that initiated it. Discard responses for older values, and expose a pending state so users know why submission is temporarily unavailable.

Custom inputs never become touched

Forward the component’s value update and blur event to the validation layer. A wrapper that emits only a custom event must translate it into the event or model contract expected by useField, Vuelidate, or your own touch function.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Server errors disappear on the next keystroke

Store server errors separately from client rule errors. Clear a field’s server message only when that field changes, and retain form-level failures until the next submission attempt.

Browser and custom messages conflict

Either use native constraint validation consistently or add novalidate and render your own messages. Mixing both without a policy can produce duplicate announcements.

Performance, reliability, and cost decisions

  • Keep inexpensive synchronous rules local and debounce network-backed rules.
  • Validate only the fields or collection rows that changed when a form is large.
  • Do not watch an entire reactive object deeply if a field-level watcher is sufficient.
  • Lazy-load a validation library for a route that contains a rarely used, complex form.
  • Keep error messages deterministic and preserve the user’s entered values after a failed request.
  • Test keyboard submission, blur order, screen-reader announcements, async cancellation, server field errors, and reset behavior.

For security and reliability, duplicate all meaningful constraints on the server and handle timeouts, retries, and malformed responses as explicit states. Client validation improves feedback; it does not establish trust.

Or skip the browser setup

If your development task is to capture a page for a form review, regression artifact, or documentation image, ScreenshotNeo returns a screenshot or PDF from one request. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the page verdict and billing status in X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

See the ScreenshotNeo API documentation for all capture options, including full-page lazy-image loading, CSS-selector element capture, device presets, custom CSS and JavaScript, waits, request blocking, cookies, headers, geolocation, PDF settings, caching, signed links, asynchronous webhooks, bulk capture, and usage reporting.

cURL

curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Use an API key kept on your server, not in browser JavaScript. To try it, sign up for ScreenshotNeo’s free plan with 1,000 screenshots each month and no card required.

Frequently Asked Questions

How should I test a Vue validation flow?

Test rule functions separately, then add component tests for blur, submit, reset, and server-error rendering. Include keyboard-only and screen-reader checks, plus an end-to-end test that submits invalid data and confirms no request is sent.

How can I localize validation messages?

Return message keys or structured error codes from rules, then resolve them through your i18n layer at render time. Keep field names and interpolation values separate from translated text so changing locale does not require rebuilding the validation model.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

What is the safest way to validate a dynamic list of fields?

Give each row a stable identity, validate additions and removals against the current collection, and send the server the complete normalized list. Model-centric trees such as Vuelidate are useful when each row has nested rules.

Can I use native browser validation with vee-validate or Vuelidate?

Yes, but choose one visible error policy. Keep native constraints when their UI meets your accessibility needs, or use `novalidate` and let the Vue layer own messages, focus, and announcements.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.