Building Forms with Vue 3
Work in progress
This page is a work in progress. Help complete it by contributing on GitHub!
- Basic Form Component
- Form Handling Patterns
- Advanced Patterns
- Form Validation Libraries
- Integration with Twig
- Best Practices
- What's Next?
- Further Reading
Forms are essential for user interaction in web applications. UserFrosting 6.0 uses Vue 3 to create reactive, validated forms that submit via AJAX, providing a smooth user experience without full page reloads.
This guide shows you how to build forms with Vue 3, handle validation, submit data to your API, and display feedback to users.
Basic Form Component
Here's a simple login form component to get started:
LoginForm.vue:
<template>
<form @submit.prevent="handleSubmit" class="uk-form-stacked">
<!-- CSRF Token (UserFrosting requirement) -->
<input type="hidden" :name="csrfName" :value="csrfValue" />
<!-- Username Field -->
<div class="uk-margin">
<label class="uk-form-label" for="username">Username</label>
<div class="uk-form-controls">
<input
id="username"
v-model="form.username"
type="text"
class="uk-input"
:class="{ 'uk-form-danger': errors.username }"
placeholder="Enter your username"
/>
<div v-if="errors.username" class="uk-text-danger uk-text-small">
{{ errors.username }}
</div>
</div>
</div>
<!-- Password Field -->
<div class="uk-margin">
<label class="uk-form-label" for="password">Password</label>
<div class="uk-form-controls">
<input
id="password"
v-model="form.password"
type="password"
class="uk-input"
:class="{ 'uk-form-danger': errors.password }"
placeholder="Enter your password"
/>
<div v-if="errors.password" class="uk-text-danger uk-text-small">
{{ errors.password }}
</div>
</div>
</div>
<!-- Remember Me Checkbox -->
<div class="uk-margin">
<label>
<input v-model="form.rememberMe" type="checkbox" class="uk-checkbox" />
Remember me
</label>
</div>
<!-- Submit Button -->
<div class="uk-margin">
<button
type="submit"
class="uk-button uk-button-primary"
:disabled="isSubmitting"
>
{{ isSubmitting ? 'Signing in...' : 'Sign in' }}
</button>
</div>
<!-- Error/Success Messages -->
<div v-if="successMessage" class="uk-alert-success" uk-alert>
<p>{{ successMessage }}</p>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
// Form state
const form = ref({
username: '',
password: '',
rememberMe: false
})
// UI state
const isSubmitting = ref(false)
const errors = ref<Record<string, string>>({})
const successMessage = ref('')
// CSRF tokens (from global site object)
const csrfName = computed(() => site.csrf.keys.name)
const csrfValue = computed(() => site.csrf.name)
// Validation
function validateForm(): boolean {
errors.value = {}
if (!form.value.username.trim()) {
errors.value.username = 'Username is required'
}
if (!form.value.password) {
errors.value.password = 'Password is required'
}
return Object.keys(errors.value).length === 0
}
// Submit handler
async function handleSubmit() {
if (!validateForm()) {
return
}
isSubmitting.value = true
errors.value = {}
successMessage.value = ''
try {
const response = await axios.post(
`${site.uri.public}/account/login`,
{
user_name: form.value.username,
password: form.value.password,
rememberme: form.value.rememberMe
},
{
headers: {
[csrfName.value]: csrfValue.value
}
}
)
successMessage.value = 'Login successful! Redirecting...'
// Redirect after success
setTimeout(() => {
window.location.href = response.data.redirect || '/dashboard'
}, 1000)
} catch (error: any) {
if (error.response?.data?.errors) {
// Server validation errors
errors.value = error.response.data.errors
} else {
errors.value.general = error.response?.data?.message || 'Login failed'
}
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.uk-form-danger {
border-color: #f0506e;
}
</style>
Form Handling Patterns
1. Two-Way Data Binding
Use v-model for automatic synchronization between form inputs and data:
<script setup lang="ts">
import { ref } from 'vue'
const form = ref({
email: '',
age: 0,
newsletter: false,
role: 'user'
})
</script>
<template>
<!-- Text input -->
<input v-model="form.email" type="email" />
<!-- Number input -->
<input v-model.number="form.age" type="number" />
<!-- Checkbox -->
<input v-model="form.newsletter" type="checkbox" />
<!-- Select dropdown -->
<select v-model="form.role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</template>
2. Form Validation
Client-Side Validation
Validate before submitting:
interface FormErrors {
[key: string]: string
}
const errors = ref<FormErrors>({})
function validateEmail(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return regex.test(email)
}
function validateForm(): boolean {
errors.value = {}
// Required fields
if (!form.value.username.trim()) {
errors.value.username = 'Username is required'
} else if (form.value.username.length < 3) {
errors.value.username = 'Username must be at least 3 characters'
}
// Email validation
if (!form.value.email) {
errors.value.email = 'Email is required'
} else if (!validateEmail(form.value.email)) {
errors.value.email = 'Please enter a valid email'
}
// Password strength
if (form.value.password.length < 8) {
errors.value.password = 'Password must be at least 8 characters'
}
return Object.keys(errors.value).length === 0
}
Server-Side Validation
Handle validation errors from the server:
async function handleSubmit() {
try {
await axios.post('/api/users', form.value)
} catch (error: any) {
if (error.response?.status === 422) {
// Laravel-style validation errors
errors.value = error.response.data.errors
} else if (error.response?.data?.errors) {
// UserFrosting-style errors
errors.value = error.response.data.errors
}
}
}
3. CSRF Protection
UserFrosting requires CSRF tokens for POST requests. Include them automatically:
<script setup lang="ts">
import { computed } from 'vue'
import axios from 'axios'
// Get CSRF from global site object
const csrfName = computed(() => site.csrf.keys.name)
const csrfValue = computed(() => site.csrf.name)
async function submitForm(data: any) {
await axios.post('/api/endpoint', data, {
headers: {
[csrfName.value]: csrfValue.value
}
})
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<!-- Hidden CSRF fields -->
<input type="hidden" :name="csrfName" :value="csrfValue" />
<!-- Rest of form... -->
</form>
</template>
4. Loading States
Show feedback during submission:
<script setup lang="ts">
import { ref } from 'vue'
const isSubmitting = ref(false)
async function handleSubmit() {
isSubmitting.value = true
try {
await axios.post('/api/endpoint', form.value)
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<button
type="submit"
:disabled="isSubmitting"
class="uk-button uk-button-primary"
>
<span v-if="isSubmitting">
<span uk-spinner="ratio: 0.5"></span> Submitting...
</span>
<span v-else>Submit</span>
</button>
</template>
Advanced Patterns
Reusable Form Composable
Create a composable for common form logic:
composables/useForm.ts:
import { ref, Ref } from 'vue'
import axios, { AxiosError } from 'axios'
interface UseFormOptions<T> {
initialData: T
onSuccess?: (data: any) => void
onError?: (error: AxiosError) => void
}
export function useForm<T extends Record<string, any>>(
options: UseFormOptions<T>
) {
const form = ref<T>({ ...options.initialData }) as Ref<T>
const errors = ref<Record<string, string>>({})
const isSubmitting = ref(false)
const isDirty = ref(false)
function reset() {
form.value = { ...options.initialData }
errors.value = {}
isDirty.value = false
}
function setErrors(serverErrors: Record<string, string | string[]>) {
errors.value = {}
for (const [field, messages] of Object.entries(serverErrors)) {
errors.value[field] = Array.isArray(messages)
? messages[0]
: messages
}
}
async function submit(url: string, method: 'post' | 'put' | 'patch' = 'post') {
isSubmitting.value = true
errors.value = {}
try {
const response = await axios[method](/url, form.value, {
headers: {
[site.csrf.keys.name]: site.csrf.name,
[site.csrf.keys.value]: site.csrf.value
}
})
options.onSuccess?.(response.data)
return response.data
} catch (error: any) {
if (error.response?.data?.errors) {
setErrors(error.response.data.errors)
}
options.onError?.(error)
throw error
} finally {
isSubmitting.value = false
}
}
return {
form,
errors,
isSubmitting,
isDirty,
reset,
setErrors,
submit
}
}
Usage:
<script setup lang="ts">
import { useForm } from '@/composables/useForm'
interface UserForm {
username: string
email: string
password: string
}
const { form, errors, isSubmitting, submit, reset } = useForm<UserForm>({
initialData: {
username: '',
email: '',
password: ''
},
onSuccess: (data) => {
alert('User created successfully!')
reset()
}
})
async function handleSubmit() {
await submit('/api/users', 'post')
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.username" />
<span v-if="errors.username">{{ errors.username }}</span>
<button type="submit" :disabled="isSubmitting">
Submit
</button>
</form>
</template>
File Upload
Handle file uploads with progress tracking:
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
const selectedFile = ref<File | null>(null)
const uploadProgress = ref(0)
const isUploading = ref(false)
function onFileSelected(event: Event) {
const target = event.target as HTMLInputElement
selectedFile.value = target.files?.[0] || null
}
async function uploadFile() {
if (!selectedFile.value) return
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append(site.csrf.keys.name, site.csrf.name)
formData.append(site.csrf.keys.value, site.csrf.value)
isUploading.value = true
uploadProgress.value = 0
try {
await axios.post('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
uploadProgress.value = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
}
}
})
alert('File uploaded successfully!')
} finally {
isUploading.value = false
}
}
</script>
<template>
<div>
<input type="file" @change="onFileSelected" />
<button
@click="uploadFile"
:disabled="!selectedFile || isUploading"
>
Upload
</button>
<div v-if="isUploading">
<progress :value="uploadProgress" max="100"></progress>
{{ uploadProgress }}%
</div>
</div>
</template>
Dynamic Form Fields
Add/remove fields dynamically:
<script setup lang="ts">
import { ref } from 'vue'
interface EmailField {
id: number
value: string
}
const emails = ref<EmailField[]>([
{ id: 1, value: '' }
])
let nextId = 2
function addEmail() {
emails.value.push({
id: nextId++,
value: ''
})
}
function removeEmail(id: number) {
emails.value = emails.value.filter(email => email.id !== id)
}
</script>
<template>
<div v-for="email in emails" :key="email.id">
<input v-model="email.value" type="email" />
<button @click="removeEmail(email.id)">Remove</button>
</div>
<button @click="addEmail">Add Email</button>
</template>
Form Validation Libraries
For complex validation, consider these libraries:
VeeValidate
VeeValidate provides comprehensive form validation:
npm install vee-validate yup
<script setup lang="ts">
import { useForm } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8),
})
const { defineField, handleSubmit, errors } = useForm({
validationSchema: schema
})
const [email] = defineField('email')
const [password] = defineField('password')
const onSubmit = handleSubmit((values) => {
console.log('Form submitted:', values)
})
</script>
<template>
<form @submit="onSubmit">
<input v-model="email" type="email" />
<span>{{ errors.email }}</span>
<input v-model="password" type="password" />
<span>{{ errors.password }}</span>
<button type="submit">Submit</button>
</form>
</template>
Integration with Twig
Render your form component in a Twig template:
templates/pages/login.html.twig:
{% extends '@admin/pages/abstract/dashboard.html.twig' %}
{% block content %}
<div id="login-form-app"></div>
{% endblock %}
{% block scripts_page %}
{{ vite_js('login.ts') }}
{% endblock %}
assets/login.ts:
import { createApp } from 'vue'
import LoginForm from './components/LoginForm.vue'
createApp(LoginForm).mount('#login-form-app')
Best Practices
1. Validate on Both Sides
Always validate on both client and server:
- Client: Immediate feedback, better UX
- Server: Security, can't be bypassed
2. Clear Error Messages
Provide specific, actionable error messages:
✅ Good: "Email must be in format: [email protected]" ❌ Bad: "Invalid input"
3. Disable Submit During Processing
Prevent duplicate submissions:
<button
type="submit"
:disabled="isSubmitting || !isValid"
>
Submit
</button>
4. Show Field-Level Errors
Display errors next to the relevant field:
<div class="uk-margin">
<input v-model="form.email" :class="{ 'uk-form-danger': errors.email }" />
<div v-if="errors.email" class="uk-text-danger">
{{ errors.email }}
</div>
</div>
5. Reset Forms After Success
Clear the form after successful submission:
async function handleSubmit() {
await submit('/api/users')
// Reset form
form.value = { ...initialData }
errors.value = {}
}
What's Next?
- Tables: Display data in sortable, filterable tables
- Collections: Manage dynamic lists of items
- Alerts: Show notifications to users