home / skills / bbeierle12 / skill-mcp-claude / form-vue

form-vue skill

/skills/form-vue

This skill provides production-ready Vue 3 form patterns using VeeValidate with Zod integration, enabling rapid, reliable form validation.

npx playbooks add skill bbeierle12/skill-mcp-claude --skill form-vue

Review the files below or copy the command above to add this skill to your agents.

Files (3)
SKILL.md
14.3 KB
---
name: form-vue
description: Production-ready Vue form patterns using VeeValidate (default) or Vuelidate with Zod integration. Use when building forms in Vue 3 applications with Composition API.
---

# Form Vue

Production Vue 3 form patterns. Default stack: **VeeValidate + Zod**.

## Quick Start

```bash
npm install vee-validate @vee-validate/zod zod
```

```vue
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

// 1. Define schema
const schema = toTypedSchema(z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Min 8 characters')
}));

// 2. Use form
const { handleSubmit, errors } = useForm({ validationSchema: schema });
const { value: email } = useField('email');
const { value: password } = useField('password');

// 3. Handle submit
const onSubmit = handleSubmit((values) => {
  console.log(values);
});
</script>

<template>
  <form @submit="onSubmit">
    <input v-model="email" type="email" autocomplete="email" />
    <span v-if="errors.email">{{ errors.email }}</span>
    
    <input v-model="password" type="password" autocomplete="current-password" />
    <span v-if="errors.password">{{ errors.password }}</span>
    
    <button type="submit">Sign in</button>
  </form>
</template>
```

## When to Use Which

| Criteria | VeeValidate | Vuelidate |
|----------|-------------|-----------|
| API Style | Declarative (schema) | Imperative (rules) |
| Zod Integration | ✅ Native adapter | Manual |
| Bundle Size | ~15KB | ~10KB |
| Component Support | ✅ Built-in Field/Form | Manual binding |
| Async Validation | ✅ Built-in | ✅ Built-in |
| Cross-field Validation | ✅ Easy | More manual |
| Learning Curve | Low | Medium |

**Default: VeeValidate** — Better DX, native Zod support.

**Use Vuelidate when:**
- Need extremely fine-grained control
- Existing Vuelidate codebase
- Prefer imperative validation style

## VeeValidate Patterns

### Basic Form with Composition API

```vue
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { loginSchema, type LoginFormData } from './schemas';

const emit = defineEmits<{
  submit: [data: LoginFormData]
}>();

// Form setup
const { handleSubmit, errors, meta } = useForm<LoginFormData>({
  validationSchema: toTypedSchema(loginSchema),
  validateOnMount: false
});

// Field setup
const { value: email, errorMessage: emailError, meta: emailMeta } = useField('email');
const { value: password, errorMessage: passwordError, meta: passwordMeta } = useField('password');
const { value: rememberMe } = useField('rememberMe');

// Submit handler
const onSubmit = handleSubmit((values) => {
  emit('submit', values);
});
</script>

<template>
  <form @submit="onSubmit" novalidate>
    <div class="form-field" :class="{ 'has-error': emailMeta.touched && emailError }">
      <label for="email">Email</label>
      <input
        id="email"
        v-model="email"
        type="email"
        autocomplete="email"
        :aria-invalid="emailMeta.touched && !!emailError"
        :aria-describedby="emailError ? 'email-error' : undefined"
      />
      <span v-if="emailMeta.touched && emailError" id="email-error" role="alert">
        {{ emailError }}
      </span>
    </div>

    <div class="form-field" :class="{ 'has-error': passwordMeta.touched && passwordError }">
      <label for="password">Password</label>
      <input
        id="password"
        v-model="password"
        type="password"
        autocomplete="current-password"
        :aria-invalid="passwordMeta.touched && !!passwordError"
        :aria-describedby="passwordError ? 'password-error' : undefined"
      />
      <span v-if="passwordMeta.touched && passwordError" id="password-error" role="alert">
        {{ passwordError }}
      </span>
    </div>

    <label class="checkbox">
      <input v-model="rememberMe" type="checkbox" />
      Remember me
    </label>

    <button type="submit" :disabled="meta.pending">
      {{ meta.pending ? 'Signing in...' : 'Sign in' }}
    </button>
  </form>
</template>
```

### Reusable FormField Component

```vue
<!-- components/FormField.vue -->
<script setup lang="ts">
import { useField } from 'vee-validate';
import { computed, useId } from 'vue';

interface Props {
  name: string;
  label: string;
  type?: string;
  autocomplete?: string;
  hint?: string;
  required?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  type: 'text'
});

const fieldId = useId();
const errorId = `${fieldId}-error`;
const hintId = `${fieldId}-hint`;

const { value, errorMessage, meta } = useField(() => props.name);

const showError = computed(() => meta.touched && !!errorMessage.value);
const showValid = computed(() => meta.touched && !errorMessage.value && meta.valid);

const describedBy = computed(() => {
  const ids = [];
  if (props.hint) ids.push(hintId);
  if (showError.value) ids.push(errorId);
  return ids.length > 0 ? ids.join(' ') : undefined;
});
</script>

<template>
  <div 
    class="form-field" 
    :class="{ 
      'form-field--error': showError,
      'form-field--valid': showValid
    }"
  >
    <label :for="fieldId">
      {{ label }}
      <span v-if="required" class="required" aria-hidden="true">*</span>
    </label>
    
    <span v-if="hint" :id="hintId" class="hint">{{ hint }}</span>
    
    <div class="input-wrapper">
      <input
        :id="fieldId"
        v-model="value"
        :type="type"
        :autocomplete="autocomplete"
        :aria-invalid="showError"
        :aria-describedby="describedBy"
        :aria-required="required"
      />
      
      <span v-if="showValid" class="icon icon--valid" aria-hidden="true">✓</span>
      <span v-if="showError" class="icon icon--error" aria-hidden="true">✗</span>
    </div>
    
    <span v-if="showError" :id="errorId" class="error" role="alert">
      {{ errorMessage }}
    </span>
  </div>
</template>
```

### Using FormField Component

```vue
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { loginSchema } from './schemas';
import FormField from './FormField.vue';

const { handleSubmit, meta } = useForm({
  validationSchema: toTypedSchema(loginSchema)
});

const onSubmit = handleSubmit((values) => {
  console.log(values);
});
</script>

<template>
  <form @submit="onSubmit" novalidate>
    <FormField
      name="email"
      label="Email"
      type="email"
      autocomplete="email"
      required
    />
    
    <FormField
      name="password"
      label="Password"
      type="password"
      autocomplete="current-password"
      required
    />
    
    <button type="submit" :disabled="meta.pending">
      Sign in
    </button>
  </form>
</template>
```

### Form with Initial Values

```vue
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { profileSchema } from './schemas';

interface Props {
  initialData?: {
    firstName: string;
    lastName: string;
    email: string;
  }
}

const props = defineProps<Props>();

const { handleSubmit, resetForm } = useForm({
  validationSchema: toTypedSchema(profileSchema),
  initialValues: props.initialData
});

// Reset to initial values
const handleCancel = () => {
  resetForm();
};

// Reset to new values
const handleReset = (newValues: typeof props.initialData) => {
  resetForm({ values: newValues });
};
</script>
```

### Async Validation (Username Check)

```vue
<script setup lang="ts">
import { useField } from 'vee-validate';
import { z } from 'zod';
import { toTypedSchema } from '@vee-validate/zod';

// Schema with async validation
const usernameSchema = z.string()
  .min(3, 'Username must be at least 3 characters')
  .refine(async (username) => {
    const response = await fetch(`/api/check-username?u=${username}`);
    const { available } = await response.json();
    return available;
  }, 'Username is already taken');

const { value, errorMessage, meta } = useField('username', toTypedSchema(usernameSchema));
</script>

<template>
  <div class="form-field">
    <label for="username">Username</label>
    <input
      id="username"
      v-model="value"
      type="text"
      autocomplete="username"
    />
    <span v-if="meta.pending" class="loading">Checking...</span>
    <span v-else-if="errorMessage" class="error">{{ errorMessage }}</span>
  </div>
</template>
```

### Cross-Field Validation (Password Confirmation)

```vue
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

const schema = toTypedSchema(
  z.object({
    password: z.string().min(8, 'Min 8 characters'),
    confirmPassword: z.string()
  }).refine(data => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword']
  })
);

const { handleSubmit } = useForm({ validationSchema: schema });
const { value: password } = useField('password');
const { value: confirmPassword, errorMessage: confirmError } = useField('confirmPassword');
</script>

<template>
  <form @submit="handleSubmit(onSubmit)">
    <input v-model="password" type="password" placeholder="Password" />
    <input v-model="confirmPassword" type="password" placeholder="Confirm password" />
    <span v-if="confirmError">{{ confirmError }}</span>
  </form>
</template>
```

### Field Arrays (Dynamic Fields)

```vue
<script setup lang="ts">
import { useForm, useFieldArray } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

const schema = toTypedSchema(z.object({
  teammates: z.array(z.object({
    name: z.string().min(1, 'Name required'),
    email: z.string().email('Invalid email')
  })).min(1, 'Add at least one teammate')
}));

const { handleSubmit } = useForm({
  validationSchema: schema,
  initialValues: {
    teammates: [{ name: '', email: '' }]
  }
});

const { fields, push, remove } = useFieldArray('teammates');
</script>

<template>
  <form @submit="handleSubmit(onSubmit)">
    <div v-for="(field, index) in fields" :key="field.key">
      <FormField :name="`teammates[${index}].name`" label="Name" />
      <FormField :name="`teammates[${index}].email`" label="Email" type="email" />
      <button type="button" @click="remove(index)" v-if="fields.length > 1">
        Remove
      </button>
    </div>
    
    <button type="button" @click="push({ name: '', email: '' })">
      Add teammate
    </button>
    
    <button type="submit">Submit</button>
  </form>
</template>
```

## Vuelidate Patterns

### Basic Form

```vue
<script setup lang="ts">
import { reactive, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength } from '@vuelidate/validators';

const state = reactive({
  email: '',
  password: ''
});

const rules = computed(() => ({
  email: { required, email },
  password: { required, minLength: minLength(8) }
}));

const v$ = useVuelidate(rules, state);

const onSubmit = async () => {
  const isValid = await v$.value.$validate();
  if (!isValid) return;
  
  console.log('Submitting:', state);
};
</script>

<template>
  <form @submit.prevent="onSubmit">
    <div class="form-field" :class="{ 'has-error': v$.email.$error }">
      <label for="email">Email</label>
      <input
        id="email"
        v-model="state.email"
        type="email"
        autocomplete="email"
        @blur="v$.email.$touch()"
      />
      <span v-if="v$.email.$error" class="error">
        {{ v$.email.$errors[0]?.$message }}
      </span>
    </div>

    <div class="form-field" :class="{ 'has-error': v$.password.$error }">
      <label for="password">Password</label>
      <input
        id="password"
        v-model="state.password"
        type="password"
        autocomplete="current-password"
        @blur="v$.password.$touch()"
      />
      <span v-if="v$.password.$error" class="error">
        {{ v$.password.$errors[0]?.$message }}
      </span>
    </div>

    <button type="submit" :disabled="v$.$pending">
      Sign in
    </button>
  </form>
</template>
```

### Vuelidate with Zod

```vue
<script setup lang="ts">
import { reactive } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { z } from 'zod';

// Create Vuelidate validator from Zod schema
function zodValidator<T extends z.ZodType>(schema: T) {
  return helpers.withMessage(
    (value: unknown) => {
      const result = schema.safeParse(value);
      if (!result.success) {
        return result.error.errors[0]?.message || 'Invalid';
      }
      return true;
    },
    (value: unknown) => {
      const result = schema.safeParse(value);
      return result.success;
    }
  );
}

const emailSchema = z.string().email('Please enter a valid email');
const passwordSchema = z.string().min(8, 'Password must be at least 8 characters');

const state = reactive({
  email: '',
  password: ''
});

const rules = {
  email: { zodValidator: zodValidator(emailSchema) },
  password: { zodValidator: zodValidator(passwordSchema) }
};

const v$ = useVuelidate(rules, state);
</script>
```

## Shared Zod Schemas

```typescript
// schemas/index.ts (shared between React and Vue)
import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string().min(1, 'Email is required').email('Invalid email'),
  password: z.string().min(1, 'Password is required'),
  rememberMe: z.boolean().optional().default(false)
});

export type LoginFormData = z.infer<typeof loginSchema>;

// VeeValidate usage
import { toTypedSchema } from '@vee-validate/zod';
const veeSchema = toTypedSchema(loginSchema);

// React Hook Form usage
import { zodResolver } from '@hookform/resolvers/zod';
const rhfResolver = zodResolver(loginSchema);
```

## File Structure

```
form-vue/
├── SKILL.md
├── references/
│   ├── veevalidate-patterns.md   # VeeValidate deep-dive
│   └── vuelidate-patterns.md     # Vuelidate deep-dive
└── scripts/
    ├── veevalidate-form.vue      # VeeValidate patterns
    ├── vuelidate-form.vue        # Vuelidate patterns
    ├── form-field.vue            # Reusable field component
    └── schemas/                  # Shared with form-validation
        ├── auth.ts
        ├── profile.ts
        └── payment.ts
```

## Reference

- `references/veevalidate-patterns.md` — Complete VeeValidate patterns
- `references/vuelidate-patterns.md` — Vuelidate patterns

Overview

This skill provides production-ready Vue 3 form patterns built around VeeValidate (default) or Vuelidate, with first-class Zod integration. It targets Composition API projects and includes reusable components, field arrays, async checks, and cross-field validation patterns to speed up building robust forms.

How this skill works

Patterns use VeeValidate by default with the @vee-validate/zod adapter to convert Zod schemas into typed validation rules, wire up fields via useForm/useField, and expose errors, meta, and pending states. Vuelidate examples show an imperative alternative and include a small helper to reuse Zod schemas with Vuelidate. Reusable FormField components and field-array helpers demonstrate accessibility, ARIA attributes, and reset/initial-value flows.

When to use it

  • Building Vue 3 apps with Composition API and typed validation
  • When you want native Zod schema support and a declarative DX (VeeValidate)
  • When you need fine-grained or imperative control over validation (Vuelidate)
  • For forms requiring async checks (username availability) or cross-field rules (password confirmation)
  • When you need reusable, accessible form field components and dynamic field arrays

Best practices

  • Prefer VeeValidate + @vee-validate/zod for most new forms for lower friction and typed schemas
  • Extract shared Zod schemas to a central module so they can be reused across frontends and components
  • Use a FormField wrapper to centralize ARIA, error display, and visual states for consistency
  • Leverage useFieldArray for dynamic lists instead of manual index management to preserve reactivity
  • Keep async validation debounced and reflect meta.pending in the UI to avoid confusing flicker

Example use cases

  • Login and registration forms with email/password validation and remember-me checkbox
  • Profile edit forms with initial values and reset-to-original or reset-to-new functionality
  • Sign-up flows that include async username availability checks
  • Complex forms with password confirmation and other cross-field constraints
  • Team or attendee lists implemented with dynamic field arrays (add/remove entries)

FAQ

Which validator should I pick for a new project?

Default to VeeValidate with the Zod adapter for the best developer experience and native schema support. Use Vuelidate only if you need extremely imperative control or are integrating into an existing Vuelidate codebase.

Can I reuse the same Zod schemas across other frameworks?

Yes. Keep Zod schemas in a shared module and convert them as needed (toTypedSchema for VeeValidate, a small helper for Vuelidate, or resolvers for other libs).