Painless forms for Vue.js with comprehensive validation, composition API, and component-based approaches.
—
Renderless Vue components for template-based form development with integrated validation. These components provide declarative APIs for building forms while maintaining full control over rendering and styling.
Renderless field component that provides validation and state management for individual form fields.
/**
* Renderless field component for individual form fields
* Provides validation, state management, and binding objects via slot props
*/
interface FieldProps {
name: string; // Field path/name (required)
rules?: RuleExpression; // Validation rules
as?: string | Component; // Render as specific element/component
validateOnMount?: boolean; // Validate when field mounts
validateOnBlur?: boolean; // Validate on blur events
validateOnChange?: boolean; // Validate on change events
validateOnInput?: boolean; // Validate on input events
validateOnModelUpdate?: boolean; // Validate on v-model updates
bails?: boolean; // Stop validation on first error
label?: string; // Field label for error messages
uncheckedValue?: any; // Value when checkbox/radio unchecked
modelValue?: any; // v-model binding value
keepValue?: boolean; // Preserve value on unmount
}
interface FieldSlotProps {
field: FieldBindingObject; // Input binding object
componentField: ComponentFieldBindingObject; // Component binding object
value: any; // Current field value
meta: FieldMeta; // Field metadata
errors: string[]; // Field errors array
errorMessage: string | undefined; // First error message
validate: () => Promise<ValidationResult>; // Manual validation trigger
resetField: (state?: Partial<FieldState>) => void; // Reset field state
handleChange: (value: any) => void; // Handle value changes
handleBlur: () => void; // Handle blur events
setValue: (value: any) => void; // Set field value
setTouched: (touched: boolean) => void; // Set touched state
setErrors: (errors: string[]) => void; // Set field errors
}
interface FieldBindingObject {
name: string;
onBlur: () => void;
onChange: (e: Event) => void;
onInput: (e: Event) => void;
value: any;
}
interface ComponentFieldBindingObject {
modelValue: any;
'onUpdate:modelValue': (value: any) => void;
onBlur: () => void;
}Field Component Examples:
<template>
<!-- Basic field with custom input -->
<Field name="email" :rules="emailRules" v-slot="{ field, errorMessage, meta }">
<input
v-bind="field"
type="email"
placeholder="Enter your email"
:class="{
'error': !meta.valid && meta.touched,
'success': meta.valid && meta.touched
}"
/>
<span v-if="errorMessage" class="error-message">{{ errorMessage }}</span>
</Field>
<!-- Field rendered as specific element -->
<Field name="message" as="textarea" rules="required" v-slot="{ field, errorMessage }">
<label>Message</label>
<!-- Field is rendered as textarea automatically -->
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<!-- Field with component binding -->
<Field name="category" rules="required" v-slot="{ componentField, errorMessage }">
<CustomSelect v-bind="componentField" :options="categories" />
<ErrorMessage name="category" />
</Field>
<!-- Checkbox field -->
<Field
name="newsletter"
type="checkbox"
:unchecked-value="false"
:value="true"
v-slot="{ field, value }"
>
<label>
<input v-bind="field" type="checkbox" />
Subscribe to newsletter ({{ value ? 'Yes' : 'No' }})
</label>
</Field>
<!-- Field with custom validation -->
<Field
name="username"
:rules="validateUsername"
v-slot="{ field, errorMessage, meta, validate }"
>
<input v-bind="field" placeholder="Username" />
<button @click="validate" :disabled="meta.pending">
{{ meta.pending ? 'Validating...' : 'Check Availability' }}
</button>
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<!-- Field with advanced state management -->
<Field
name="password"
rules="required|min:8"
v-slot="{ field, meta, errors, setValue, setTouched }"
>
<input
v-bind="field"
type="password"
placeholder="Password"
@focus="setTouched(true)"
/>
<!-- Password strength indicator -->
<div class="password-strength">
<div
v-for="error in errors"
:key="error"
class="strength-rule"
:class="{ 'met': !errors.includes(error) }"
>
{{ error }}
</div>
</div>
<button @click="setValue(generatePassword())">
Generate Strong Password
</button>
</Field>
</template>
<script setup lang="ts">
import { Field, ErrorMessage } from 'vee-validate';
const emailRules = (value: string) => {
if (!value) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Email is invalid';
return true;
};
const validateUsername = async (value: string) => {
if (!value) return 'Username is required';
if (value.length < 3) return 'Username too short';
// Async validation
const response = await fetch(`/api/check-username?username=${value}`);
const { available } = await response.json();
return available || 'Username is taken';
};
const categories = [
{ value: 'tech', label: 'Technology' },
{ value: 'design', label: 'Design' },
{ value: 'business', label: 'Business' }
];
const generatePassword = () => {
return Math.random().toString(36).slice(-12) + 'A1!';
};
</script>Form wrapper component that provides validation context and handles form submission.
/**
* Form wrapper component with validation context
* Provides form state management and submission handling via slot props
*/
interface FormProps {
as?: string | Component; // Render as specific element (default: 'form')
validationSchema?: object; // Form validation schema
initialValues?: object; // Initial field values
initialErrors?: object; // Initial field errors
initialTouched?: object; // Initial field touched states
validateOnMount?: boolean; // Validate when form mounts
onSubmit?: SubmissionHandler; // Form submission handler
onInvalidSubmit?: InvalidSubmissionHandler; // Invalid submission handler
keepValues?: boolean; // Preserve values on unmount
name?: string; // Form identifier
}
interface FormSlotProps {
// Form state
values: Record<string, any>; // Current form values
errors: Record<string, string>; // Current form errors
meta: FormMeta; // Form metadata
isSubmitting: boolean; // Submission state
isValidating: boolean; // Validation state
submitCount: number; // Submission attempt count
// Form methods
handleSubmit: (e?: Event) => Promise<void>; // Form submission handler
handleReset: () => void; // Form reset handler
validate: () => Promise<FormValidationResult>; // Manual form validation
validateField: (field: string) => Promise<ValidationResult>; // Manual field validation
// State mutations
setFieldValue: (field: string, value: any) => void; // Set field value
setFieldError: (field: string, error: string) => void; // Set field error
setErrors: (errors: Record<string, string>) => void; // Set multiple errors
setValues: (values: Record<string, any>) => void; // Set multiple values
setTouched: (touched: Record<string, boolean>) => void; // Set touched states
resetForm: (state?: Partial<FormState>) => void; // Reset form
resetField: (field: string, state?: Partial<FieldState>) => void; // Reset field
}Form Component Examples:
<template>
<!-- Basic form with validation schema -->
<Form
:validationSchema="schema"
:initial-values="initialValues"
@submit="onSubmit"
v-slot="{ errors, meta, isSubmitting }"
>
<Field name="name" v-slot="{ field, errorMessage }">
<input v-bind="field" placeholder="Full Name" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<Field name="email" v-slot="{ field, errorMessage }">
<input v-bind="field" type="email" placeholder="Email" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<button
type="submit"
:disabled="!meta.valid || isSubmitting"
>
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
<!-- Form-level error display -->
<div v-if="Object.keys(errors).length > 0" class="form-errors">
<h4>Please fix the following errors:</h4>
<ul>
<li v-for="(error, field) in errors" :key="field">
{{ field }}: {{ error }}
</li>
</ul>
</div>
</Form>
<!-- Advanced form with manual submission -->
<Form
:validation-schema="userSchema"
v-slot="{
values,
errors,
meta,
handleSubmit,
setFieldValue,
setErrors,
resetForm,
isSubmitting
}"
>
<Field name="username" v-slot="{ field, errorMessage }">
<input v-bind="field" placeholder="Username" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<Field name="email" v-slot="{ field, errorMessage }">
<input v-bind="field" type="email" placeholder="Email" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<!-- Manual submission buttons -->
<button @click="handleSubmit(submitUser)" :disabled="!meta.valid">
Create User
</button>
<button @click="handleSubmit(saveDraft)" type="button">
Save as Draft
</button>
<button @click="resetForm" type="button">
Reset Form
</button>
<!-- Programmatic field updates -->
<button @click="setFieldValue('username', generateUsername())">
Generate Username
</button>
<!-- Form progress -->
<div class="form-progress">
Progress: {{ Math.round((Object.keys(values).filter(key => values[key]).length / Object.keys(schema).length) * 100) }}%
</div>
</Form>
<!-- Form with custom validation handling -->
<Form
@submit="handleFormSubmit"
@invalid-submit="handleInvalidSubmit"
v-slot="{ meta, submitCount }"
>
<Field name="data" rules="required" v-slot="{ field, errorMessage }">
<input v-bind="field" placeholder="Enter data" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<button type="submit">Submit</button>
<div v-if="submitCount > 0">
Attempts: {{ submitCount }}
</div>
</Form>
</template>
<script setup lang="ts">
import { Form, Field } from 'vee-validate';
import * as yup from 'yup';
const schema = yup.object({
name: yup.string().required('Name is required'),
email: yup.string().email('Invalid email').required('Email is required')
});
const userSchema = yup.object({
username: yup.string().min(3).required(),
email: yup.string().email().required()
});
const initialValues = {
name: '',
email: ''
};
const onSubmit = async (values: any) => {
console.log('Form submitted:', values);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Form submitted successfully!');
};
const submitUser = async (values: any, { setErrors }: any) => {
try {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(values)
});
if (!response.ok) {
const errors = await response.json();
setErrors(errors);
return;
}
alert('User created successfully!');
} catch (error) {
setErrors({ '': 'Network error occurred' });
}
};
const saveDraft = async (values: any) => {
await fetch('/api/drafts', {
method: 'POST',
body: JSON.stringify(values)
});
alert('Draft saved!');
};
const generateUsername = () => {
return 'user_' + Math.random().toString(36).substr(2, 9);
};
const handleFormSubmit = (values: any) => {
console.log('Valid form submitted:', values);
};
const handleInvalidSubmit = ({ errors, values }: any) => {
console.log('Invalid form submission:', { errors, values });
alert('Please fix form errors before submitting');
};
</script>Component for managing dynamic arrays of form fields with built-in manipulation methods.
/**
* Dynamic array field management component
* Provides array manipulation methods via slot props
*/
interface FieldArrayProps {
name: string; // Array field path (required)
}
interface FieldArraySlotProps {
fields: FieldEntry[]; // Array of field entries
push: (value: any) => void; // Add item to end
remove: (index: number) => void; // Remove item by index
swap: (indexA: number, indexB: number) => void; // Swap two items
insert: (index: number, value: any) => void; // Insert item at index
replace: (newArray: any[]) => void; // Replace entire array
update: (index: number, value: any) => void; // Update item at index
prepend: (value: any) => void; // Add item to beginning
move: (oldIndex: number, newIndex: number) => void; // Move item to new position
}
interface FieldEntry {
value: any; // Entry value
key: string | number; // Unique key for tracking
isFirst: boolean; // True if first entry
isLast: boolean; // True if last entry
}FieldArray Component Examples:
<template>
<!-- Simple array of strings -->
<FieldArray name="tags" v-slot="{ fields, push, remove }">
<div v-for="(entry, index) in fields" :key="entry.key" class="tag-item">
<Field :name="`tags[${index}]`" v-slot="{ field, errorMessage }">
<input v-bind="field" placeholder="Enter tag" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<button @click="remove(index)" type="button">Remove</button>
</div>
<button @click="push('')" type="button">Add Tag</button>
</FieldArray>
<!-- Complex array of objects -->
<FieldArray name="users" v-slot="{ fields, push, remove, swap, move }">
<div v-for="(entry, index) in fields" :key="entry.key" class="user-item">
<div class="user-fields">
<Field :name="`users[${index}].name`" v-slot="{ field, errorMessage }">
<input v-bind="field" placeholder="Name" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<Field :name="`users[${index}].email`" v-slot="{ field, errorMessage }">
<input v-bind="field" type="email" placeholder="Email" />
<span v-if="errorMessage">{{ errorMessage }}</span>
</Field>
<Field :name="`users[${index}].role`" v-slot="{ field }">
<select v-bind="field">
<option value="">Select Role</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="guest">Guest</option>
</select>
</Field>
</div>
<div class="user-actions">
<button @click="remove(index)" type="button">Remove</button>
<button
@click="move(index, index - 1)"
:disabled="entry.isFirst"
type="button"
>
Move Up
</button>
<button
@click="move(index, index + 1)"
:disabled="entry.isLast"
type="button"
>
Move Down
</button>
</div>
</div>
<button @click="push(defaultUser)" type="button">Add User</button>
<button @click="push(defaultUser, 0)" type="button">Add User at Top</button>
</FieldArray>
<!-- Advanced field array with drag and drop -->
<FieldArray
name="sortableItems"
v-slot="{ fields, remove, swap, update }"
>
<draggable
v-model="fields"
@end="handleDragEnd"
item-key="key"
>
<template #item="{ element: entry, index }">
<div class="sortable-item">
<div class="drag-handle">⋮⋮</div>
<Field :name="`sortableItems[${index}].title`" v-slot="{ field }">
<input v-bind="field" placeholder="Item title" />
</Field>
<Field :name="`sortableItems[${index}].description`" v-slot="{ field }">
<textarea v-bind="field" placeholder="Description"></textarea>
</Field>
<button @click="remove(index)" type="button">×</button>
</div>
</template>
</draggable>
</FieldArray>
</template>
<script setup lang="ts">
import { FieldArray, Field } from 'vee-validate';
import draggable from 'vuedraggable';
const defaultUser = {
name: '',
email: '',
role: ''
};
const handleDragEnd = (event: any) => {
// Draggable automatically updates the fields array
console.log('Items reordered:', event);
};
</script>Conditional error message display component that only renders when a field has an error.
/**
* Conditional error message display component
* Only renders when specified field has an error message
*/
interface ErrorMessageProps {
name: string; // Field path to show error for (required)
as?: string; // Render as specific element
}
interface ErrorMessageSlotProps {
message: string | undefined; // Error message or undefined
}ErrorMessage Component Examples:
<template>
<!-- Basic error message display -->
<Field name="email" rules="required|email" v-slot="{ field }">
<input v-bind="field" type="email" placeholder="Email" />
</Field>
<ErrorMessage name="email" />
<!-- Custom error message styling -->
<Field name="password" rules="required|min:8" v-slot="{ field }">
<input v-bind="field" type="password" placeholder="Password" />
</Field>
<ErrorMessage name="password" as="div" class="error-text" />
<!-- Error message with custom slot -->
<Field name="username" rules="required" v-slot="{ field }">
<input v-bind="field" placeholder="Username" />
</Field>
<ErrorMessage name="username" v-slot="{ message }">
<div v-if="message" class="error-container">
<icon name="warning" />
<span>{{ message }}</span>
</div>
</ErrorMessage>
<!-- Multiple error messages for different fields -->
<div class="form-group">
<Field name="firstName" rules="required" v-slot="{ field }">
<input v-bind="field" placeholder="First Name" />
</Field>
<ErrorMessage name="firstName" />
</div>
<div class="form-group">
<Field name="lastName" rules="required" v-slot="{ field }">
<input v-bind="field" placeholder="Last Name" />
</Field>
<ErrorMessage name="lastName" />
</div>
<!-- Error message for nested fields -->
<Field name="address.street" rules="required" v-slot="{ field }">
<input v-bind="field" placeholder="Street Address" />
</Field>
<ErrorMessage name="address.street" />
<Field name="address.city" rules="required" v-slot="{ field }">
<input v-bind="field" placeholder="City" />
</Field>
<ErrorMessage name="address.city" />
<!-- Conditional error message display -->
<Field name="phone" :rules="phoneRules" v-slot="{ field, meta }">
<input v-bind="field" placeholder="Phone Number" />
</Field>
<ErrorMessage
name="phone"
v-slot="{ message }"
>
<div v-if="message && showPhoneError" class="phone-error">
{{ message }}
<button @click="showPhoneError = false">Dismiss</button>
</div>
</ErrorMessage>
</template>
<script setup lang="ts">
import { Field, ErrorMessage } from 'vee-validate';
import { ref } from 'vue';
const showPhoneError = ref(true);
const phoneRules = (value: string) => {
if (!value) return 'Phone number is required';
if (!/^\d{10}$/.test(value)) return 'Phone number must be 10 digits';
return true;
};
</script>
<style scoped>
.error-text {
color: red;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.error-container {
display: flex;
align-items: center;
gap: 0.5rem;
color: red;
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1rem;
}
.phone-error {
background: #fee;
border: 1px solid #fcc;
padding: 0.5rem;
border-radius: 4px;
color: #c00;
}
</style>Combining different field types in a comprehensive form.
<template>
<Form
:validation-schema="schema"
:initial-values="initialValues"
@submit="onSubmit"
v-slot="{ meta, isSubmitting }"
>
<!-- Text input -->
<Field name="name" v-slot="{ field, errorMessage }">
<label>Full Name</label>
<input v-bind="field" type="text" />
<ErrorMessage name="name" />
</Field>
<!-- Email input with custom validation -->
<Field name="email" v-slot="{ field, errorMessage, meta }">
<label>Email Address</label>
<input
v-bind="field"
type="email"
:class="{ valid: meta.valid && meta.touched }"
/>
<ErrorMessage name="email" />
</Field>
<!-- Select dropdown -->
<Field name="country" v-slot="{ field }">
<label>Country</label>
<select v-bind="field">
<option value="">Select Country</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
</select>
<ErrorMessage name="country" />
</Field>
<!-- Checkbox -->
<Field
name="agreeToTerms"
type="checkbox"
:value="true"
v-slot="{ field }"
>
<label>
<input v-bind="field" type="checkbox" />
I agree to the terms and conditions
</label>
<ErrorMessage name="agreeToTerms" />
</Field>
<!-- Dynamic field array -->
<FieldArray name="hobbies" v-slot="{ fields, push, remove }">
<label>Hobbies</label>
<div v-for="(entry, index) in fields" :key="entry.key">
<Field :name="`hobbies[${index}]`" v-slot="{ field }">
<input v-bind="field" placeholder="Enter hobby" />
</Field>
<button @click="remove(index)" type="button">Remove</button>
</div>
<button @click="push('')" type="button">Add Hobby</button>
</FieldArray>
<!-- Submit button -->
<button
type="submit"
:disabled="!meta.valid || isSubmitting"
>
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</Form>
</template>Install with Tessl CLI
npx tessl i tessl/npm-vee-validate