Forms and Validation: DataAnnotations to React Hook Form and VeeValidate
For .NET engineers who know: Model binding,
DataAnnotations,ModelState,IValidatableObject, and Razor form tag helpers You’ll learn: How React Hook Form and VeeValidate replace the DataAnnotations pipeline — and how sharing a single Zod schema between your API and your form eliminates duplicate validation logic Time: 15-20 minutes
The .NET Way (What You Already Know)
In ASP.NET, forms are backed by a model. The model carries its validation rules via DataAnnotations attributes. The framework binds incoming form data to the model, runs the validation pipeline, and populates ModelState. Your controller checks ModelState.IsValid and either proceeds or returns the errors to the view. The entire cycle is automatic once you wire up the model.
// The model — validation lives in attributes
public class CreateOrderRequest
{
[Required(ErrorMessage = "Customer name is required")]
[MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
[Required]
[Range(1, 10000, ErrorMessage = "Quantity must be between 1 and 10,000")]
public int Quantity { get; set; }
[Required]
[EmailAddress]
public string ContactEmail { get; set; } = string.Empty;
[CreditCard]
public string? CardNumber { get; set; }
}
// The controller — ModelState checked automatically by [ApiController]
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request)
{
// [ApiController] automatically returns 400 if ModelState.IsValid is false
// You never check ModelState.IsValid here — it is handled by the framework
var order = await _orderService.CreateAsync(request);
return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
}
}
// The Razor view
<form asp-action="Create">
<input asp-for="CustomerName" class="form-control" />
<span asp-validation-for="CustomerName" class="text-danger"></span>
<input asp-for="Quantity" type="number" />
<span asp-validation-for="Quantity" class="text-danger"></span>
<button type="submit">Place Order</button>
</form>
The appeal of this model is the single source of truth: the C# class drives everything — binding, validation, Swagger types, and form display. When you add [MaxLength(50)] to a property, it affects server validation, and if you use client-side validation libraries, it can affect that too.
The problem in the JS world: there is no equivalent of “attribute-driven validation on a class” by default. The form library, the validation library, and your TypeScript types are three separate concerns that you have to connect yourself — unless you use Zod, which does exactly that.
The React Way
The Libraries Involved
Three libraries work together:
- React Hook Form — manages form state, tracks which fields have been touched, handles submission
- Zod — defines the schema: types and validation rules in one place
- @hookform/resolvers — the bridge that connects a Zod schema to React Hook Form’s validation pipeline
Install them:
npm install react-hook-form zod @hookform/resolvers
Defining the Schema with Zod
In the .NET model, DataAnnotations are attributes on properties. In Zod, you describe an object’s shape and validation rules in code:
// schemas/order.schema.ts
import { z } from "zod";
export const createOrderSchema = z.object({
customerName: z
.string()
.min(1, "Customer name is required")
.max(100, "Customer name must be 100 characters or fewer"),
quantity: z
.number({ invalid_type_error: "Quantity must be a number" })
.int("Quantity must be a whole number")
.min(1, "Quantity must be at least 1")
.max(10000, "Quantity cannot exceed 10,000"),
contactEmail: z
.string()
.min(1, "Contact email is required")
.email("Enter a valid email address"),
cardNumber: z
.string()
.regex(/^\d{16}$/, "Card number must be 16 digits")
.optional(),
});
// Derive the TypeScript type from the schema — one definition, two uses
export type CreateOrderFormValues = z.infer<typeof createOrderSchema>;
z.infer<typeof createOrderSchema> generates a TypeScript type from the schema. This is the equivalent of your C# model class — except it is inferred from the same object that defines the validation rules. You do not write the type and the validation separately.
Basic Form with React Hook Form
// components/CreateOrderForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createOrderSchema, type CreateOrderFormValues } from "../schemas/order.schema";
export function CreateOrderForm() {
const {
register, // connects <input> to the form
handleSubmit, // wraps your submit handler, prevents default, runs validation
formState: { errors, isSubmitting, isValid, touchedFields },
reset, // clears the form
} = useForm<CreateOrderFormValues>({
resolver: zodResolver(createOrderSchema),
defaultValues: {
customerName: "",
quantity: 1,
contactEmail: "",
},
mode: "onBlur", // validate when user leaves a field (like touching a field away)
});
const onSubmit = async (data: CreateOrderFormValues) => {
// data is fully typed and validated — TypeScript knows all fields
await orderApi.create(data);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* customerName field */}
<div>
<label htmlFor="customerName">Customer Name</label>
<input
id="customerName"
{...register("customerName")}
aria-describedby={errors.customerName ? "customerName-error" : undefined}
aria-invalid={!!errors.customerName}
/>
{errors.customerName && (
<span id="customerName-error" role="alert">
{errors.customerName.message}
</span>
)}
</div>
{/* quantity field */}
<div>
<label htmlFor="quantity">Quantity</label>
<input
id="quantity"
type="number"
{...register("quantity", { valueAsNumber: true })}
aria-invalid={!!errors.quantity}
/>
{errors.quantity && (
<span role="alert">{errors.quantity.message}</span>
)}
</div>
{/* contactEmail field */}
<div>
<label htmlFor="contactEmail">Email</label>
<input
id="contactEmail"
type="email"
{...register("contactEmail")}
aria-invalid={!!errors.contactEmail}
/>
{errors.contactEmail && (
<span role="alert">{errors.contactEmail.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Placing Order..." : "Place Order"}
</button>
</form>
);
}
The register("fieldName") call returns { name, ref, onChange, onBlur } — the four props React Hook Form needs to track that input. The spread {...register("customerName")} attaches all four in one line.
handleSubmit(onSubmit) does three things: prevents the default form submission, runs all validations, and only calls your onSubmit function if everything passes. Your submit handler receives typed, validated data.
Controlled Inputs vs. Uncontrolled Inputs
This is the key architectural choice in React Hook Form. Understanding it maps directly to something you know from WinForms or Blazor.
Uncontrolled inputs — React Hook Form’s default. The DOM holds the value. React Hook Form reads from the DOM when it needs to validate or submit. There is no React state update on every keystroke. This is fast and the right choice for most form fields.
// Uncontrolled — the DOM holds the value
<input {...register("customerName")} />
Controlled inputs — React state drives the value. Every keystroke triggers a re-render. Required when your UI must react to the value in real time (character counter, live preview, conditional field visibility based on typed content).
// Controlled — you manage state, React Hook Form watches it
const { control } = useForm<FormValues>();
<Controller
name="customerName"
control={control}
render={({ field, fieldState }) => (
<CustomInput
{...field}
error={fieldState.error?.message}
characterCount={field.value.length}
/>
)}
/>
Use Controller when integrating third-party components (date pickers, rich text editors, custom select components) that manage their own internal state and expose a value/onChange interface.
| Approach | When to use | Re-renders |
|---|---|---|
register() (uncontrolled) | Standard inputs, file inputs, most cases | Only on validation / submit |
Controller (controlled) | Custom components, third-party UI libs, value-dependent UI | Every keystroke |
Sharing Zod Schemas Between API and Form
This is the insight that eliminates most duplicate validation code. The same Zod schema that validates form input can also validate API request bodies on the server (with NestJS + zod-validation-pipe, or Express middleware).
// shared/schemas/order.schema.ts
// This file can live in a shared package imported by both frontend and backend
import { z } from "zod";
export const createOrderSchema = z.object({
customerName: z.string().min(1).max(100),
quantity: z.number().int().min(1).max(10000),
contactEmail: z.string().email(),
});
export type CreateOrderDto = z.infer<typeof createOrderSchema>;
// Backend (NestJS) — same schema validates incoming API requests
// orders.controller.ts
import { createOrderSchema, CreateOrderDto } from "@myapp/shared/schemas";
import { ZodValidationPipe } from "nestjs-zod";
@Post()
@UsePipes(new ZodValidationPipe(createOrderSchema))
async create(@Body() dto: CreateOrderDto) {
return this.ordersService.create(dto);
}
// Frontend — same schema drives form validation
// CreateOrderForm.tsx
import { createOrderSchema, type CreateOrderDto } from "@myapp/shared/schemas";
import { zodResolver } from "@hookform/resolvers/zod";
const { register, handleSubmit } = useForm<CreateOrderDto>({
resolver: zodResolver(createOrderSchema),
});
This is the JS equivalent of having your DataAnnotations model shared between an ASP.NET controller and a Blazor form component. In a monorepo (Article 1.4), you publish the schema from a packages/shared directory and import it in both apps/api and apps/web.
File Uploads
File inputs do not work with Zod’s type inference directly — the browser File object is not a JSON-serializable type. Use register() and access the file from FileList:
const fileSchema = z.object({
name: z.string().min(1),
attachment: z
.custom<FileList>()
.refine((files) => files.length > 0, "A file is required")
.refine(
(files) => files[0]?.size <= 5 * 1024 * 1024,
"File must be smaller than 5MB"
)
.refine(
(files) => ["image/jpeg", "image/png", "application/pdf"].includes(files[0]?.type),
"Only JPG, PNG, or PDF files are accepted"
),
});
type FileFormValues = z.infer<typeof fileSchema>;
function FileUploadForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FileFormValues>({
resolver: zodResolver(fileSchema),
});
const onSubmit = async (data: FileFormValues) => {
const formData = new FormData();
formData.append("name", data.name);
formData.append("attachment", data.attachment[0]);
await fetch("/api/upload", { method: "POST", body: formData });
// Do NOT set Content-Type header — the browser sets multipart/form-data with boundary
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
<input type="file" {...register("attachment")} accept=".jpg,.png,.pdf" />
{errors.attachment && <span role="alert">{errors.attachment.message}</span>}
<button type="submit">Upload</button>
</form>
);
}
Multi-Step Forms
Multi-step forms (wizards) are common and React Hook Form handles them well. The pattern: one useForm instance at the top level, each step renders a subset of fields, validation runs per-step with a partial schema:
// schemas/registration.schema.ts
export const stepOneSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Enter a valid email"),
});
export const stepTwoSchema = z.object({
plan: z.enum(["starter", "pro", "enterprise"], {
errorMap: () => ({ message: "Select a plan" }),
}),
billingCycle: z.enum(["monthly", "annual"]),
});
// Full schema for final submission
export const registrationSchema = stepOneSchema.merge(stepTwoSchema);
export type RegistrationFormValues = z.infer<typeof registrationSchema>;
// components/RegistrationWizard.tsx
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
registrationSchema,
stepOneSchema,
stepTwoSchema,
type RegistrationFormValues,
} from "../schemas/registration.schema";
export function RegistrationWizard() {
const [step, setStep] = useState(1);
const form = useForm<RegistrationFormValues>({
resolver: zodResolver(registrationSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
plan: "starter",
billingCycle: "monthly",
},
mode: "onBlur",
});
const advanceToStepTwo = async () => {
// Trigger validation only on step one's fields
const valid = await form.trigger(["firstName", "lastName", "email"]);
if (valid) setStep(2);
};
const onSubmit = async (data: RegistrationFormValues) => {
await registrationApi.create(data);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && (
<>
<input {...form.register("firstName")} placeholder="First name" />
{form.formState.errors.firstName && (
<span role="alert">{form.formState.errors.firstName.message}</span>
)}
<input {...form.register("lastName")} placeholder="Last name" />
<input {...form.register("email")} type="email" placeholder="Email" />
<button type="button" onClick={advanceToStepTwo}>Next</button>
</>
)}
{step === 2 && (
<>
<select {...form.register("plan")}>
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
<button type="button" onClick={() => setStep(1)}>Back</button>
<button type="submit">Complete Registration</button>
</>
)}
</form>
);
}
The Vue Way: VeeValidate
VeeValidate is the Vue equivalent of React Hook Form. It integrates with Zod through the same @vee-validate/zod adapter. The same schema can back both a React and a Vue form.
npm install vee-validate @vee-validate/zod zod
// components/CreateOrderForm.vue
<script setup lang="ts">
import { useForm, useField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import { createOrderSchema, type CreateOrderFormValues } from "../schemas/order.schema";
const { handleSubmit, errors, isSubmitting, meta } = useForm<CreateOrderFormValues>({
validationSchema: toTypedSchema(createOrderSchema),
initialValues: {
customerName: "",
quantity: 1,
contactEmail: "",
},
});
// useField binds a single field — returns value, error messages, and blur handler
const { value: customerName, errorMessage: customerNameError } = useField<string>("customerName");
const { value: quantity, errorMessage: quantityError } = useField<number>("quantity");
const { value: contactEmail, errorMessage: contactEmailError } = useField<string>("contactEmail");
const onSubmit = handleSubmit(async (values) => {
await orderApi.create(values);
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<div>
<label for="customerName">Customer Name</label>
<input
id="customerName"
v-model="customerName"
:aria-invalid="!!customerNameError"
:aria-describedby="customerNameError ? 'customerName-error' : undefined"
/>
<span v-if="customerNameError" id="customerName-error" role="alert">
{{ customerNameError }}
</span>
</div>
<div>
<label for="quantity">Quantity</label>
<input id="quantity" v-model.number="quantity" type="number" />
<span v-if="quantityError" role="alert">{{ quantityError }}</span>
</div>
<div>
<label for="contactEmail">Email</label>
<input id="contactEmail" v-model="contactEmail" type="email" />
<span v-if="contactEmailError" role="alert">{{ contactEmailError }}</span>
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? "Placing Order..." : "Place Order" }}
</button>
</form>
</template>
The key difference from React Hook Form: VeeValidate uses v-model binding (Vue’s two-way data binding), while React Hook Form uses register() which attaches refs. Both connect to the same Zod schema through their respective adapters.
Key Differences
| Concept | ASP.NET DataAnnotations | React Hook Form + Zod | VeeValidate + Zod |
|---|---|---|---|
| Validation rules | Attributes on C# class | z.object() schema | z.object() schema (same) |
| Type generation | The model class is the type | z.infer<typeof schema> | z.infer<typeof schema> (same) |
| Form binding | asp-for tag helper | register("field") | v-model="fieldValue" |
| Error display | asp-validation-for | {errors.field?.message} | {{ fieldError }} |
| Submit handling | Controller action | handleSubmit(fn) | handleSubmit(fn) |
| ModelState | Built-in | formState.errors | errors from useForm |
| Shared schema | N/A — .csproj references | Shared package / monorepo | Same shared package |
Gotchas for .NET Engineers
Gotcha 1: HTML inputs return strings — number fields need explicit coercion
In ASP.NET model binding, the framework converts the "42" string from the form POST to the int property automatically. In React Hook Form, <input type="number"> still returns a string from the DOM. Zod will reject "42" when the schema expects number.
// WRONG — Zod receives "42" (string) from the input, fails validation
<input type="number" {...register("quantity")} />
// CORRECT — valueAsNumber tells React Hook Form to coerce the DOM string
<input type="number" {...register("quantity", { valueAsNumber: true })} />
// ALSO CORRECT — use z.coerce.number() in the schema instead
const schema = z.object({
quantity: z.coerce.number().int().min(1),
// z.coerce.number() calls Number() on the value before validating
});
The valueAsNumber option in register() and z.coerce.number() in Zod are both valid approaches. Pick one per project. We use valueAsNumber on the field so the schema stays honest about its expected types.
Gotcha 2: Validation mode defaults to “onSubmit” — users see no errors until they click submit
The default mode in React Hook Form is "onSubmit". Users fill in an invalid email, tab away, and see nothing wrong until they try to submit the form. This feels broken compared to the instant inline validation your .NET forms may have provided via jQuery Unobtrusive Validation.
// Default behavior — errors appear only after first submit attempt
const form = useForm({ resolver: zodResolver(schema) });
// Better for most forms — validate when the user leaves a field
const form = useForm({
resolver: zodResolver(schema),
mode: "onBlur",
});
// Best for forms where correctness matters more than noise
// "onTouched" = validate onBlur, then revalidate onChange after first blur
const form = useForm({
resolver: zodResolver(schema),
mode: "onTouched",
});
| mode | When validation fires |
|---|---|
"onSubmit" | Only when user submits (default) |
"onBlur" | When user leaves a field (tab away) |
"onChange" | On every keystroke — can be noisy |
"onTouched" | onBlur first, then onChange for touched fields |
"all" | Both onChange and onBlur |
Gotcha 3: The submit handler only fires if all fields are valid — silent prevention
In ASP.NET MVC, if ModelState.IsValid is false, you re-render the view. The developer code always runs. In React Hook Form, handleSubmit(yourFn) only calls yourFn if validation passes. If the form is invalid, it populates formState.errors and does nothing else. No error is thrown, no rejection occurs — validation failure is silent from the perspective of your handler.
// This function will NEVER be called if form is invalid
const onSubmit = async (data: FormValues) => {
console.log("This only runs if Zod says data is valid");
await api.create(data);
};
// If you need to know what happened when submit failed validation, listen to the second argument
<form onSubmit={form.handleSubmit(onSubmit, (errors) => {
// errors is the formState.errors object — all current validation failures
console.log("Form validation failed:", errors);
analytics.track("form_validation_failed", { fields: Object.keys(errors) });
})}>
Gotcha 4: Unregistered fields are not included in the submitted data
In ASP.NET model binding, any property on the model is populated from the POST data regardless of whether there is a form field for it. In React Hook Form, only fields registered with register() or Controller appear in the data object passed to your submit handler.
// If your Zod schema has a "userId" field but you don't register it in the form,
// userId will be undefined in onSubmit's data, and Zod will either fail validation
// or return undefined depending on whether the field is optional.
// Solution: use setValue() to set programmatic values that aren't form inputs
const { register, handleSubmit, setValue } = useForm<FormValues>();
useEffect(() => {
// Set hidden/computed fields before submission
setValue("userId", currentUser.id);
setValue("createdAt", new Date().toISOString());
}, [currentUser.id]);
Gotcha 5: noValidate on the form element is required to suppress browser-native validation
Browsers have built-in validation for type="email", required, min, max. Without noValidate, the browser intercepts the submit event before React Hook Form does, shows its own ugly validation tooltips, and your custom error messages never appear.
// WRONG — browser validation fights with React Hook Form
<form onSubmit={handleSubmit(onSubmit)}>
// CORRECT — browser validation disabled, React Hook Form takes full control
<form onSubmit={handleSubmit(onSubmit)} noValidate>
Hands-On Exercise
Build a two-step job application form backed by a shared Zod schema.
Step 1 — Define the schema in schemas/job-application.schema.ts:
import { z } from "zod";
export const personalInfoSchema = z.object({
firstName: z.string().min(1, "First name is required").max(50),
lastName: z.string().min(1, "Last name is required").max(50),
email: z.string().email("Enter a valid email address"),
phone: z
.string()
.regex(/^\+?[\d\s\-()]{10,}$/, "Enter a valid phone number")
.optional()
.or(z.literal("")),
});
export const experienceSchema = z.object({
yearsOfExperience: z.coerce
.number()
.int()
.min(0, "Cannot be negative")
.max(50, "That seems like a lot"),
currentTitle: z.string().min(1, "Current title is required"),
coverLetter: z
.string()
.min(100, "Cover letter must be at least 100 characters")
.max(2000, "Cover letter must be 2,000 characters or fewer"),
resume: z
.custom<FileList>()
.refine((f) => f.length > 0, "Resume is required")
.refine((f) => f[0]?.size <= 2 * 1024 * 1024, "Resume must be under 2MB")
.refine(
(f) => f[0]?.type === "application/pdf",
"Resume must be a PDF"
),
});
export const jobApplicationSchema = personalInfoSchema.merge(experienceSchema);
export type JobApplicationValues = z.infer<typeof jobApplicationSchema>;
Step 2 — Build the two-step form in React. The form should:
- Validate step one fields (
firstName,lastName,email,phone) before advancing withform.trigger(["firstName", "lastName", "email", "phone"]) - Show a character counter on the
coverLetterfield using a controlledControllerinput - Display inline errors below each field as soon as the user has touched and left that field (
mode: "onTouched") - Show a loading spinner on the submit button during submission (
isSubmitting) - Use proper
aria-invalidandaria-describedbyattributes for accessibility
Step 3 — Add a refinement to the schema to check that the email domain is not a temporary email service:
.refine(
(data) => !["mailinator.com", "guerrillamail.com"].includes(data.email.split("@")[1]),
{ message: "Use a permanent email address", path: ["email"] }
)
Step 4 — Simulate the server validation scenario. After the form submits, use React Hook Form’s setError to surface a server-side error:
const onSubmit = async (data: JobApplicationValues) => {
try {
await jobApi.apply(data);
} catch (err) {
if (err instanceof ApiError && err.code === "EMAIL_TAKEN") {
form.setError("email", { message: "This email has already applied" });
}
}
};
Quick Reference
| Task | React Hook Form | VeeValidate |
|---|---|---|
| Initialize form | useForm({ resolver: zodResolver(schema) }) | useForm({ validationSchema: toTypedSchema(schema) }) |
| Register input | {...register("fieldName")} | v-model="fieldValue" from useField |
| Access error | errors.fieldName?.message | errorMessage from useField |
| Wrap submit | handleSubmit(fn) | handleSubmit(fn) |
| Set programmatic value | setValue("field", value) | setFieldValue("field", value) |
| Trigger validation | trigger("field") or trigger(["f1", "f2"]) | validate() |
| Watch a field value | watch("fieldName") | Reactive via v-model |
| Reset form | reset() or reset(defaultValues) | resetForm() |
| Surface server error | setError("field", { message: "..." }) | setFieldError("field", "...") |
| Controlled component | <Controller name="..." control={control} render={...} /> | <Field name="..." v-slot="{ field }" /> |
| Submission state | formState.isSubmitting | isSubmitting from useForm |
| Form validity | formState.isValid | meta.valid |
| File input | register("file") + valueAsNumber not needed | useField("file") |
DataAnnotations to Zod cheat sheet
| DataAnnotation | Zod equivalent |
|---|---|
[Required] | .min(1) (string) or .min(1) (array) |
[MaxLength(n)] | .max(n) |
[MinLength(n)] | .min(n) |
[Range(min, max)] | .min(min).max(max) |
[EmailAddress] | .email() |
[Url] | .url() |
[RegularExpression(pattern)] | .regex(/pattern/) |
[Compare("OtherField")] | .superRefine() or .refine() on the object |
[CreditCard] | .regex(/^\d{16}$/) (simplified) |
[Phone] | .regex(/^\+?[\d\s\-()+]+$/) |
[EnumDataType(typeof MyEnum)] | z.nativeEnum(MyEnum) or z.enum(["a","b"]) |
IValidatableObject.Validate() | .superRefine((data, ctx) => {...}) |
[CustomValidation] | .refine((val) => ..., "message") |
Further Reading
- React Hook Form documentation — official docs; the API reference for
useForm,Controller, anduseFormContext - Zod documentation — full schema API reference; especially the
.refine(),.superRefine(), and.transform()sections - VeeValidate with Zod — the integration guide for the Vue adapter
- Article 2.3 — TypeScript Type System Deep Dive — covers
z.infer<>and TypeScript’s structural type system in context