From vue-plugin
Vue 3 form patterns: native v-model, vee-validate + zod (most common), VueUse helpers, defineModel for custom inputs, controlled vs uncontrolled, field arrays, multi-step wizards. Use this skill to: - Wire vee-validate with a validation schema (zod / yup). - Build custom form components with defineModel (Vue 3.4+). - Implement multi-step forms. - Handle async validation. - Integrate forms with TanStack Query mutations or Pinia actions. Do NOT use this skill for: - General SFC conventions (see vue-conventions). - State management (see vue-state-management). - Routing (see vue-routing). - Testing forms (see vue-testing).
How this skill is triggered — by the user, by Claude, or both
Slash command
/vue-plugin:vue-formsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
`v-model` is the foundation. Add vee-validate when validation grows beyond simple HTML5 attributes.
v-model is the foundation. Add vee-validate when validation grows beyond simple HTML5 attributes.
| Marker (in deps) | Library |
|---|---|
vee-validate (+ @vee-validate/zod or @vee-validate/yup) | vee-validate (recommended) |
@vueuse/core | VueUse helpers (lighter alternative) |
| (none) | Native v-model + HTML5 validation |
zod / yup / valibot | Pair with vee-validate via resolver |
<script setup lang="ts">
import { ref } from 'vue';
const email = ref('');
const password = ref('');
const remember = ref(false);
function onSubmit() {
// ...
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model="email" type="email" required placeholder="Email" />
<input v-model="password" type="password" required minlength="8" placeholder="Password" />
<label>
<input v-model="remember" type="checkbox" /> Remember me
</label>
<button>Log in</button>
</form>
</template>
Built-in HTML5 validation (required, type="email", pattern, minlength, maxlength) is free. Use it for forms ≤3 fields with simple rules.
<input v-model.lazy="text" /> <!-- updates on change, not input -->
<input v-model.number="age" /> <!-- coerces to number -->
<input v-model.trim="username" /> <!-- trims whitespace -->
In Vue 3.4+, use defineModel:
<!-- CustomInput.vue -->
<script setup lang="ts">
const value = defineModel<string>();
const error = defineModel<string | null>('error', { default: null });
</script>
<template>
<div>
<input v-model="value" />
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<!-- Parent -->
<CustomInput v-model="form.email" v-model:error="emailError" />
For Vue 3.0–3.3, use manual props + emits:
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value);
}
</script>
<template>
<input :value="props.modelValue" @input="onInput" />
</template>
pnpm add vee-validate @vee-validate/zod zod
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const schema = toTypedSchema(z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
remember: z.boolean().default(false),
}));
const { defineField, handleSubmit, errors, isSubmitting } = useForm({
validationSchema: schema,
initialValues: { email: '', password: '', remember: false },
});
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
const [remember, rememberAttrs] = defineField('remember');
const onSubmit = handleSubmit(async (values) => {
await login(values);
});
</script>
<template>
<form @submit="onSubmit">
<label>
Email
<input v-model="email" v-bind="emailAttrs" type="email" />
<p v-if="errors.email" role="alert">{{ errors.email }}</p>
</label>
<label>
Password
<input v-model="password" v-bind="passwordAttrs" type="password" />
<p v-if="errors.password" role="alert">{{ errors.password }}</p>
</label>
<label>
<input v-model="remember" v-bind="rememberAttrs" type="checkbox" />
Remember me
</label>
<button :disabled="isSubmitting">
{{ isSubmitting ? 'Logging in...' : 'Log in' }}
</button>
</form>
</template>
<script setup lang="ts">
import { Field, ErrorMessage, useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
useForm({
validationSchema: toTypedSchema(z.object({
email: z.string().email(),
password: z.string().min(8),
})),
});
</script>
<template>
<form>
<Field name="email" type="email" />
<ErrorMessage name="email" as="p" role="alert" />
<Field name="password" type="password" />
<ErrorMessage name="password" />
</form>
</template>
<Field> and <ErrorMessage> are convenient but less flexible. Pick one approach per project.
<script setup lang="ts">
import { useForm, useFieldArray } from 'vee-validate';
const { handleSubmit } = useForm({
initialValues: { contacts: [{ email: '' }] },
});
const { fields, push, remove } = useFieldArray<{ email: string }>('contacts');
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<div v-for="(field, index) in fields" :key="field.key">
<Field :name="`contacts[${index}].email`" type="email" />
<ErrorMessage :name="`contacts[${index}].email`" />
<button type="button" @click="remove(index)">Remove</button>
</div>
<button type="button" @click="push({ email: '' })">Add</button>
<button type="submit">Save</button>
</form>
</template>
field.key is a stable key from vee-validate — DON'T use index.
const schema = z.object({
username: z.string().min(3).refine(
async (u) => {
const res = await fetch(`/api/users/check?u=${u}`);
return (await res.json()).available;
},
'Username taken'
),
});
const { defineField } = useForm({
validationSchema: toTypedSchema(schema),
validateOnInput: false,
validateOnBlur: true, // validate on blur — appropriate for expensive checks
});
const { handleSubmit, setErrors, setFieldError } = useForm({...});
const onSubmit = handleSubmit(async (values) => {
try {
await createUser(values);
} catch (err) {
if (err instanceof FetchError && err.status === 409) {
setFieldError('email', 'Email already in use');
} else {
setErrors({ form: 'Something went wrong' });
}
}
});
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { ref } from 'vue';
const step = ref(0);
const { defineField, validate, values, handleSubmit } = useForm({
validationSchema: schema,
initialValues: { step1: {}, step2: {} },
});
async function next() {
const result = await validate(); // validates whole form
if (result.valid) step.value++;
// OR validate just current step's fields:
// const r = await validate({ mode: 'silent' });
// if (r.valid) step.value++;
}
const onSubmit = handleSubmit((vals) => save(vals));
</script>
<template>
<form @submit="onSubmit">
<Step1 v-if="step === 0" />
<Step2 v-if="step === 1" />
<button v-if="step < lastStep" type="button" @click="next">Next</button>
<button v-else type="submit">Submit</button>
</form>
</template>
@vueuse/core doesn't have a direct useForm equivalent. For simple forms, native v-model + manual validation is enough. For complex forms, use vee-validate.
VueUse useful form-related composables:
useDebouncedRef — debounce input value.useStorage — persist form state to localStorage (NEVER for sensitive data).onClickOutside — close dropdowns / pickers.useFocus — focus management for accessibility.<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
import { useCreateUser } from '@/composables/useUsers';
import { useRouter } from 'vue-router';
const router = useRouter();
const { mutate: createUser, isPending } = useCreateUser();
const schema = toTypedSchema(z.object({
email: z.string().email(),
name: z.string().min(1),
}));
const { handleSubmit, setFieldError, defineField } = useForm({ validationSchema: schema });
const [email, emailAttrs] = defineField('email');
const [name, nameAttrs] = defineField('name');
const onSubmit = handleSubmit(async (values) => {
createUser(values, {
onSuccess: () => router.push('/users'),
onError: (err) => {
if (err.status === 409) setFieldError('email', 'Email already in use');
},
});
});
</script>
<label> (visible or via aria-label).aria-invalid on inputs with errors.role="alert" on error messages (or aria-live="polite").validateOnBlur.vee-validate and formkit in same project without clear boundary.npx claudepluginhub aratkruglik/claude-sdlc --plugin vue-pluginImplements form validation using React Hook Form, Formik, Vee-Validate, and custom validators with TypeScript type safety. Use for real-time error feedback and complex validation rules.
Vue 3 SFC structure, Composition API + <script setup>, file naming, project layout, props/emits/slots typing, defineModel, composables, lifecycle, watchers, UI library detection. Vue 2 fallback notes. Use this skill to: - Structure a new SFC with `<script setup>`, template, scoped styles. - Type props/emits/slots correctly via macros. - Pick `ref` vs `reactive` (prefer `ref`). - Build composables that compose cleanly. - Apply lifecycle hooks correctly. - Detect UI library (Vuetify/Quasar/PrimeVue/Naive UI/Element Plus/shadcn-vue) and mirror its patterns. Do NOT use this skill for: - State management lib choice (see vue-state-management). - Routing (see vue-routing). - Forms (see vue-forms). - Testing (see vue-testing).
Form patterns for React: react-hook-form (most common), Formik (legacy/stable), TanStack Form (newer). Validation via zod / yup / valibot. Controlled vs uncontrolled inputs, field arrays, multi-step wizards. Use this skill to: - Wire react-hook-form with a validation schema. - Pick between controlled and uncontrolled patterns. - Build multi-step forms with state preservation. - Handle async validation (e.g., username availability). - Integrate forms with TanStack Query mutations. Do NOT use this skill for: - General state management (see react-state-management). - Routing (see react-routing). - Component conventions (see react-conventions). - Testing forms (see react-testing).