Table of Contents
- Prerequisites
- Setting Up the Project
- Core Concepts: Reactive Form State
- Dynamic Field Management
- Handling Different Input Types
- Form Validation
- Form Submission
- Advanced Features
- Best Practices
- References
Prerequisites
Before diving in, ensure you have:
- Basic knowledge of Vue.js (components, props, reactivity,
v-for,v-model). - Familiarity with JavaScript/ES6+ (arrow functions, destructuring, modules).
- Node.js (v14+) and npm/yarn installed (to set up the Vue project).
Setting Up the Project
We’ll use Vite (Vue’s recommended build tool) to scaffold a new project. Run the following commands in your terminal:
# Create a new Vue project
npm create vite@latest dynamic-forms -- --template vue
cd dynamic-forms
# Install dependencies
npm install
# Start the development server
npm run dev
This creates a basic Vue 3 project with a src/ directory containing your app code. We’ll work primarily in src/components/ and src/App.vue.
Core Concepts: Reactive Form State
At the heart of any dynamic form is reactive state management. Vue’s reactivity system (powered by ref and reactive) ensures the UI updates automatically when form data changes.
Step 1: Define Form State
Let’s start with a simple form that collects user information. We’ll use ref for primitive values and reactive for objects (though ref works for objects too—Vue unwraps them automatically).
Create a component DynamicForm.vue in src/components/:
<!-- src/components/DynamicForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<!-- Form fields will go here -->
<button type="submit">Submit</button>
</form>
</template>
<script setup>
import { ref, reactive } from 'vue';
// Define form state reactively
const form = reactive({
name: '',
email: '',
hobbies: [], // For dynamic hobbies (we'll expand this later)
});
// Submit handler
const handleSubmit = () => {
console.log('Form submitted:', form);
// Add API call logic here (e.g., fetch.post('/api/users', form))
};
</script>
Here, form is a reactive object holding our form data. Any changes to form.name or form.email will automatically update the UI.
Dynamic Field Management
The key to dynamic forms is rendering fields based on a reactive array. For example, let’s let users add multiple “hobby” fields.
Step 1: Define a Reactive Array for Fields
Update form.hobbies to be an array of objects (each representing a hobby field):
// Inside <script setup>
const form = reactive({
name: '',
email: '',
hobbies: [{ id: 1, value: '' }], // Start with 1 hobby field
});
Step 2: Render Fields with v-for
Use v-for to loop over form.hobbies and render an input for each:
<template>
<form @submit.prevent="handleSubmit">
<!-- Name Field -->
<div class="field">
<label>Name:</label>
<input
type="text"
v-model="form.name"
placeholder="Enter your name"
>
</div>
<!-- Email Field -->
<div class="field">
<label>Email:</label>
<input
type="email"
v-model="form.email"
placeholder="Enter your email"
>
</div>
<!-- Dynamic Hobbies -->
<div class="hobbies-section">
<h3>Hobbies (Add as many as you like)</h3>
<div v-for="(hobby, index) in form.hobbies" :key="hobby.id" class="hobby-field">
<input
type="text"
v-model="hobby.value"
placeholder="Enter a hobby"
>
<button
type="button"
@click="removeHobby(index)"
:disabled="form.hobbies.length === 1"
>
Remove
</button>
</div>
<button type="button" @click="addHobby">Add Hobby</button>
</div>
<button type="submit">Submit</button>
</form>
</template>
Step 3: Add/Remove Fields
Define addHobby and removeHobby methods to modify the hobbies array:
// Inside <script setup>
// Add a new hobby field
const addHobby = () => {
const newId = Date.now(); // Unique ID for :key
form.hobbies.push({ id: newId, value: '' });
};
// Remove a hobby field by index
const removeHobby = (index) => {
form.hobbies.splice(index, 1);
};
Now users can click “Add Hobby” to add new fields and “Remove” to delete them (but we disable “Remove” if there’s only one hobby left).
Handling Different Input Types
Dynamic forms often include various input types (text, checkbox, select, radio). Let’s expand our form to support these.
1. Checkboxes
For a list of checkboxes (e.g., “Interests”), bind v-model to an array to collect selected values:
<!-- Add to template -->
<div class="interests-section">
<h3>Interests (Select all that apply)</h3>
<div v-for="interest in interests" :key="interest.id" class="checkbox">
<input
type="checkbox"
:id="interest.id"
:value="interest.label"
v-model="form.interests"
>
<label :for="interest.id">{{ interest.label }}</label>
</div>
</div>
Define interests and update form in the script:
// Inside <script setup>
const form = reactive({
// ... existing fields
interests: [], // For checkboxes
});
// List of interests
const interests = [
{ id: 'tech', label: 'Technology' },
{ id: 'art', label: 'Art' },
{ id: 'sports', label: 'Sports' },
];
2. Select Dropdowns
For a dropdown (e.g., “Country”), bind v-model to a single value and use v-for to render options:
<!-- Add to template -->
<div class="field">
<label>Country:</label>
<select v-model="form.country">
<option value="">Select a country</option>
<option v-for="country in countries" :value="country.code" :key="country.code">
{{ country.name }}
</option>
</select>
</div>
// Inside <script setup>
const form = reactive({
// ... existing fields
country: '', // For select dropdown
});
// List of countries
const countries = [
{ code: 'us', name: 'United States' },
{ code: 'ca', name: 'Canada' },
{ code: 'uk', name: 'United Kingdom' },
];
3. Radio Buttons
For radio buttons (e.g., “Account Type”), bind v-model to a single value:
<!-- Add to template -->
<div class="account-type">
<h3>Account Type</h3>
<div v-for="type in accountTypes" :key="type.id" class="radio">
<input
type="radio"
:id="type.id"
:value="type.value"
v-model="form.accountType"
>
<label :for="type.id">{{ type.label }}</label>
</div>
</div>
// Inside <script setup>
const form = reactive({
// ... existing fields
accountType: '', // For radio buttons
});
// Account types
const accountTypes = [
{ id: 'personal', label: 'Personal', value: 'personal' },
{ id: 'business', label: 'Business', value: 'business' },
];
Form Validation
No form is complete without validation. We’ll use VeeValidate (a popular Vue validation library) to handle required fields, email formats, and custom rules.
Step 1: Install VeeValidate
Install VeeValidate and its rule package:
npm install @vee-validate/vue @vee-validate/rules
Step 2: Define Validation Rules
Use VeeValidate’s useForm composable to define rules and access errors. Update DynamicForm.vue:
<script setup>
import { ref, reactive } from 'vue';
import { useForm } from 'vee-validate';
import { required, email, minLength } from '@vee-validate/rules';
// Define validation rules
const { handleSubmit: veeHandleSubmit, errors } = useForm({
validationSchema: {
name: required('Name is required'),
email: [required('Email is required'), email('Invalid email format')],
'hobbies.*.value': minLength(3, 'Hobby must be at least 3 characters'), // Validate all hobbies
},
});
// Wrap our submit handler with VeeValidate's handleSubmit
const onSubmit = (values) => {
console.log('Validated values:', values);
// Submit logic here
};
// Combine with our form submission
const handleSubmit = veeHandleSubmit(onSubmit);
</script>
Step 3: Display Error Messages
Add error messages below each field using VeeValidate’s errors object:
<!-- Name field with error -->
<div class="field">
<label>Name:</label>
<input
type="text"
v-model="form.name"
placeholder="Enter your name"
name="name" <!-- Required for VeeValidate to track the field -->
>
<p class="error" v-if="errors.name">{{ errors.name }}</p>
</div>
<!-- Email field with error -->
<div class="field">
<label>Email:</label>
<input
type="email"
v-model="form.email"
placeholder="Enter your email"
name="email"
>
<p class="error" v-if="errors.email">{{ errors.email }}</p>
</div>
<!-- Hobby fields with errors -->
<div v-for="(hobby, index) in form.hobbies" :key="hobby.id" class="hobby-field">
<input
type="text"
v-model="hobby.value"
placeholder="Enter a hobby"
:name="`hobbies[${index}].value`" <!-- Dynamic name for nested validation -->
>
<button type="button" @click="removeHobby(index)" :disabled="form.hobbies.length === 1">
Remove
</button>
<p class="error" v-if="errors[`hobbies[${index}].value`]">
{{ errors[`hobbies[${index}].value`] }}
</p>
</div>
Now, validation errors will display if fields are invalid (e.g., empty name, invalid email, or short hobbies).
Form Submission
To submit the form, we’ll:
- Prevent default form behavior (
@submit.prevent). - Validate the form with VeeValidate.
- Send data to an API (e.g., using
fetchor Axios).
Here’s an updated onSubmit function with API integration:
const onSubmit = async (values) => {
try {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!response.ok) throw new Error('Submission failed');
alert('Form submitted successfully!');
} catch (err) {
alert('Error: ' + err.message);
}
};
Advanced Features
1. Conditional Fields
Show/hide fields based on user input (e.g., show “Company Name” if “Business” account is selected):
<!-- Add to template -->
<div v-if="form.accountType === 'business'" class="field">
<label>Company Name:</label>
<input
type="text"
v-model="form.companyName"
placeholder="Enter company name"
name="companyName"
>
<p class="error" v-if="errors.companyName">{{ errors.companyName }}</p>
</div>
Update validation to include companyName when needed:
// In validationSchema
companyName: {
required: (value, { root }) => root.accountType === 'business' ? 'Company name is required' : false,
}
2. Nested Forms
For nested data (e.g., a user with an address), use nested objects in form:
const form = reactive({
// ... existing fields
address: {
street: '',
city: '',
zip: '',
},
});
Render nested fields with dot notation:
<div class="address-section">
<h3>Address</h3>
<div class="field">
<label>Street:</label>
<input type="text" v-model="form.address.street" name="address.street">
</div>
<!-- City and Zip fields similarly -->
</div>
Best Practices
- Reuse Components: Extract repeated fields (e.g.,
FormInput.vue,FormCheckbox.vue) into reusable components. - Accessibility: Use
<label>withforattributes,aria-required, andaria-invalidfor screen readers. - Debounce Inputs: For fields triggering API calls (e.g., username availability), use
setTimeoutto debounce. - Test Forms: Use Vue Test Utils to test form submission, validation, and dynamic behavior.
References
- Vue.js Official Docs: Form Input Bindings
- VeeValidate Docs
- Vue School: Dynamic Forms Tutorial
- GitHub Repo: Vue Dynamic Forms Example
By following this guide, you’ve learned to build flexible, validated dynamic forms with Vue.js. The key is leveraging Vue’s reactivity to manage form state and VeeValidate for validation. Experiment with nested forms, conditional fields, and API integration to create powerful user experiences! 🚀