React 19.2.8 shipped native form Actions and the useActionState hook this year, and Vue 3.6 landed Vapor Mode in July 2026. Both changes touched how developers build forms, yet neither replaced the two libraries that still own form validation in each ecosystem: React Hook Form on the React side and VeeValidate on the Vue side. React Hook Form’s own GitHub repository underscores how actively it’s still maintained, having shipped version 7.88.0 in September 2026 as its 621st tagged release. This tutorial builds the same signup form twice, once in each stack, wires up Zod schema validation on both, and measures what actually differs: bundle size, validation timing, TypeScript ergonomics, and how much code you write to handle the same five fields.
By the end you will have two working projects, a shared validation schema pattern you can reuse on a server, and a clear answer to the question every team asks when picking a stack in 2026: which form setup costs less in bundle weight and dev time. React Hook Form currently carries roughly 45,000 GitHub stars against VeeValidate’s 11,300, and Snyk’s vulnerability database lists zero direct vulnerabilities for the library as of its 8.0.0-beta.4 release in September 2026 — the same Snyk listing puts React Hook Form’s weekly download count at 66.5 million, but stars and download counts do not tell you how the code actually reads once you are five fields deep with conditional logic. This guide will.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why Form Handling Still Needs a Library in 2026
React 19’s form Actions let a <form> tag accept a function directly in its action prop, and useActionState tracks pending state and results without a single useState call. For a two-field contact form, that native pattern removes the need for a library entirely. But once you add cross-field validation, conditional fields, or a multi-step wizard, the native approach turns into hand-rolled plumbing fast. Guides comparing React Hook Form against React 19’s native actions consistently land on the same conclusion: simple forms don’t need RHF anymore, complex forms still do, because RHF still wins on field-level subscriptions, schema reuse, and devtools.
Vue’s Vapor Mode tells a different story. It’s a compile-time option in Vue 3.6 that skips the virtual DOM for the components that opt in, cutting runtime overhead for large static trees. It changes how Vue renders, not how you validate a field, so VeeValidate’s composition-API pattern carries over into a Vapor-mode component without any code change. The practical result: React’s 2026 story is about libraries competing with new native APIs, while Vue’s story is about a rendering engine upgrade sitting underneath a validation layer that hasn’t needed to change.
Both ecosystems converged on one more thing this year: schema-first validation. Zod crossed 43,800 GitHub stars and sees over 200 million weekly npm downloads as of September 2026, and both React Hook Form and VeeValidate ship official resolver packages that plug a Zod schema straight into the form’s validation pipeline. That’s the pattern this tutorial uses, because it’s the one you’ll reuse on your API route or server action later.
Server rendering adds one more wrinkle worth flagging before you start. React Hook Form’s hooks only run on the client, so any component that imports useForm inside a React Server Components tree needs an explicit "use client" directive at the top of the file, or the build fails with an unclear error about hooks being called outside a component. VeeValidate doesn’t hit the same wall, since Vue’s server-rendering model hydrates the whole component rather than splitting server and client components the way React does, but it does mean the first paint on a Vue SSR page won’t have validation wired up until hydration finishes. Neither issue blocks this tutorial, since both demo projects run as plain client-rendered Vite apps, but keep it in mind before you drop this pattern into a Next.js or Nuxt project.
Prerequisites and Versions You’ll Need
Install these before you start. Version mismatches are the number one cause of the TypeScript errors you’ll hit in the troubleshooting section further down.
- Node.js 20 LTS or newer (Node 22 LTS recommended — several 2026 tooling releases, including the current React Router major line, target Node 22+)
- npm 10.x or pnpm 9.x
- React 19.2.8 (current stable patch, released July 21, 2026)
- Vite 6.x for scaffolding both projects
- Vue 3.6 (Vapor Mode line) or Vue 3.5.42 if you’re not ready to opt into Vapor
- react-hook-form 7.88.0
- zod 4.6.5
- @hookform/resolvers (latest, provides the Zod adapter for RHF)
- vee-validate 4.15.1 (stable; 5.0.0-beta.1 exists but isn’t production-ready yet)
- @vee-validate/zod (VeeValidate’s Zod resolver package)
- TypeScript 5.x
- VS Code, Cursor, or another editor with TypeScript IntelliSense
You’ll need about 100 minutes total: 40 minutes for the React side, 40 for Vue, and 20 to run the bundle-size comparison and fix the pitfalls you’ll inevitably hit on the first pass.
React vs Vue Forms at a Glance
Before writing code, it helps to see where the two setups actually diverge. The table below pulls together the current version numbers, adoption data, and bundle weight for each library as of September 2026 — GitHub lists React Hook Form’s latest stable release as 7.88.0, published this month, while vee-validate’s latest stable, 4.15.1, has held steady since June 2025.
| Library | Latest Version | GitHub Stars | Gzipped Size | Validation Model |
|---|---|---|---|---|
| React Hook Form | 7.88.0 | ~45,000 | ~9.6–10.7 KB | Uncontrolled inputs + refs |
| Formik (legacy) | 2.4.5 | ~34,300 | ~13–15.5 KB | Controlled inputs |
| TanStack Form | 1.12.3 (v2 alpha in testing) | ~6,600 | Varies by adapter | Headless, framework-agnostic |
| VeeValidate | 4.15.1 stable | ~11,300 | ~11–17 KB (minimal) / ~30–34 KB (full) | Composition API + reactive refs |
| Zod | 4.6.5 | ~43,800 | ~5–6 KB (v4 core), ~1.8–4 KB (mini) | Schema-first, TypeScript-native |
| Valibot | 1.4.1 | ~9,000 | ~1.3–8 KB (tree-shaken) | Modular, function-based schema |
The headline number worth remembering: React Hook Form’s core sits under 11 KB gzipped, roughly a third the size of VeeValidate’s full bundle. That gap narrows if you use VeeValidate’s minimal build, but out of the box, RHF ships lighter. Neither difference matters for a five-field signup form on a fast connection, but it adds up across a large enterprise app with dozens of form routes.
Step 1: Scaffold the React Project
Start with a clean Vite + React + TypeScript project.
npm create vite@latest react-forms-demo -- --template react-ts
cd react-forms-demo
npm install
npm install react-hook-form zod @hookform/resolvers
npm run dev
Confirm the dev server boots on localhost:5173 before moving on. If it doesn’t, jump ahead to the troubleshooting section — a broken Vite scaffold at this stage is almost always a Node version mismatch.
Step 2: Scaffold the Vue Project
In a separate directory, scaffold the Vue equivalent.
npm create vite@latest vue-forms-demo -- --template vue-ts
cd vue-forms-demo
npm install
npm install vee-validate @vee-validate/zod zod
npm run dev
Both projects now have the dependencies installed. Keep both dev servers running in separate terminal tabs — you’ll switch between them constantly while comparing behavior side by side.
Step 3: Define a Shared Zod Schema
Write the validation rules once, in plain Zod, before touching either UI framework. This is the piece you’ll eventually move to a shared package or a server route, so keep it framework-free.
// schema.ts — used identically on both the React and Vue side
import { z } from "zod";
export const signupSchema = z
.object({
fullName: z.string().min(2, "Enter your full name"),
email: z.string().email("Enter a valid email address"),
password: z.string().min(10, "Password must be at least 10 characters"),
confirmPassword: z.string(),
plan: z.enum(["free", "pro", "team"]),
teamSize: z.coerce.number().int().min(1).optional(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
.refine((data) => data.plan !== "team" || (data.teamSize ?? 0) > 1, {
message: "Team plans need at least 2 seats",
path: ["teamSize"],
});
export type SignupFormValues = z.infer<typeof signupSchema>;
Notice the two .refine() calls: one cross-field check (password confirmation) and one conditional check (team size only matters on the team plan). These are the two patterns that separate a trivial form tutorial from a realistic one, and both libraries need to handle them cleanly for this comparison to mean anything.
Step 4: Build the React Hook Form Component
Copy the schema file into src/schema.ts in the React project, then build the form component. Make sure @hookform/resolvers is current — GitHub’s release history shows the package added full Zod 4, Zod Mini, and Zod 3 compatibility in version 5.2.0 back in July 2025, then followed up with a Zod 4 output-type fix in 5.2.2 that September, and older versions predating that line can silently mismatch newer Zod releases.
// SignupForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupFormValues } from "./schema";
export function SignupForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
mode: "onBlur",
});
const plan = watch("plan");
const onSubmit = async (values: SignupFormValues) => {
await new Promise((r) => setTimeout(r, 600)); // simulate an API call
console.log("submitted", values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("fullName")} placeholder="Full name" />
{errors.fullName && <p>{errors.fullName.message}</p>}
<input {...register("email")} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" {...register("confirmPassword")} placeholder="Confirm password" />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<select {...register("plan")}>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
{plan === "team" && (
<>
<input type="number" {...register("teamSize")} placeholder="Team size" />
{errors.teamSize && <p>{errors.teamSize.message}</p>}
</>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating account..." : "Sign up"}
</button>
</form>
);
}
Run this and submit with mismatched passwords. You should see the error attached to confirmPassword, not a generic top-level error — that’s the path option in the Zod refine doing its job. React Hook Form’s watch("plan") call re-renders only the piece of the tree that depends on the plan field, which is the field-level subscription model RHF is known for. That same fine-grained design showed up again in version 7.87.0, released August 2026 per Yarn’s registry listing, which added a shouldTouch option to the trigger() method so you can mark a field as touched without waiting for a blur event — handy once you wire up the async check in the next step.
Step 5: Add Async Field Validation in React
Real signup forms check whether an email is already taken. Add an async validator through RHF’s register options rather than the Zod schema, since Zod’s refine doesn’t await network calls cleanly inside a synchronous resolver chain.
<input
{...register("email", {
validate: async (value) => {
const taken = await checkEmailTaken(value);
return taken ? "That email is already registered" : true;
},
})}
placeholder="Email"
/>
RHF runs the Zod schema and the inline validate function together, merging the resulting errors into the same errors.email object. You don’t need a second error-display path.
Step 6: Build the VeeValidate Form Component
Switch to the Vue project. VeeValidate’s composition API uses useForm and useField in a script-setup block, and the Zod resolver comes from the official @vee-validate/zod package.
<!-- SignupForm.vue -->
<script setup lang="ts">
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import { signupSchema } from "./schema";
const { defineField, handleSubmit, errors, isSubmitting, values } = useForm({
validationSchema: toTypedSchema(signupSchema),
validateOnMount: false,
});
const [fullName, fullNameAttrs] = defineField("fullName");
const [email, emailAttrs] = defineField("email");
const [password, passwordAttrs] = defineField("password");
const [confirmPassword, confirmPasswordAttrs] = defineField("confirmPassword");
const [plan, planAttrs] = defineField("plan");
const [teamSize, teamSizeAttrs] = defineField("teamSize");
const onSubmit = handleSubmit(async (formValues) => {
await new Promise((r) => setTimeout(r, 600));
console.log("submitted", formValues);
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<input v-model="fullName" v-bind="fullNameAttrs" placeholder="Full name" />
<p v-if="errors.fullName">{{ errors.fullName }}</p>
<input v-model="email" v-bind="emailAttrs" placeholder="Email" />
<p v-if="errors.email">{{ errors.email }}</p>
<input type="password" v-model="password" v-bind="passwordAttrs" placeholder="Password" />
<p v-if="errors.password">{{ errors.password }}</p>
<input type="password" v-model="confirmPassword" v-bind="confirmPasswordAttrs" placeholder="Confirm password" />
<p v-if="errors.confirmPassword">{{ errors.confirmPassword }}</p>
<select v-model="plan" v-bind="planAttrs">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<template v-if="values.plan === 'team'">
<input type="number" v-model="teamSize" v-bind="teamSizeAttrs" placeholder="Team size" />
<p v-if="errors.teamSize">{{ errors.teamSize }}</p>
</template>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? "Creating account..." : "Sign up" }}
</button>
</form>
</template>
The structure mirrors the React version almost line for line, which is the point: both libraries solved the same problem, just with different primitives. RHF spreads a register() object onto the input; VeeValidate pairs v-model with a v-bind attrs object from defineField. Conditional rendering with values.plan === 'team' reads the live form state the same way watch("plan") does in React.
Step 7: Add Async Field Validation in Vue
VeeValidate supports async rules directly through defineRule or by adding a custom Zod .refine() with an async function on the schema itself, since VeeValidate awaits the whole schema resolution before reporting errors.
// schema.ts addition for async email check
export const signupSchemaAsync = signupSchema.refine(
async (data) => !(await checkEmailTaken(data.email)),
{ message: "That email is already registered", path: ["email"] }
);
Swap toTypedSchema(signupSchema) for toTypedSchema(signupSchemaAsync) in the useForm call. VeeValidate’s resolver awaits the async refine automatically — no separate validate function needed, unlike the RHF pattern from Step 5.
Step 8: Handle Server-Side Errors on Submit
Client-side validation catches malformed input, but the server still owns the source of truth — a race condition could let two people claim the same email between your async check and the actual submit. Both libraries let you set a field error manually after a failed API call.
// React Hook Form — inside onSubmit
import { useForm } from "react-hook-form";
// ...
const { setError } = useForm<SignupFormValues>({ resolver: zodResolver(signupSchema) });
const onSubmit = async (values: SignupFormValues) => {
const res = await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
if (res.status === 409) {
setError("email", { message: "Email taken between check and submit" });
return;
}
};
VeeValidate exposes the same idea through setFieldError, returned from useForm alongside handleSubmit. Both approaches keep the error state and the display logic in the same place you already wrote for client-side errors — no separate error-banner component required.
Step 9: Reuse the Schema on a Server Route
The entire point of putting validation in Zod rather than inline library rules is that the same signupSchema file works unmodified in a server route.
// api/signup.ts (works the same whether it backs the React or Vue frontend)
import { signupSchema } from "../schema";
export async function POST(request: Request) {
const body = await request.json();
const parsed = signupSchema.safeParse(body);
if (!parsed.success) {
return new Response(JSON.stringify(parsed.error.flatten()), { status: 400 });
}
// parsed.data is now fully typed and validated
return new Response(JSON.stringify({ ok: true }), { status: 201 });
}
This is the payoff for choosing a schema-first library over inline validation rules: you write the password-confirmation and team-size logic exactly once, and it protects both the browser and the API.
Step 10: Swap in Valibot for a Smaller Bundle
If bundle size matters more than Zod’s ecosystem size, Valibot’s tree-shaken schemas run as small as roughly 1.3 KB gzipped for a typical form, against Zod’s 5–6 KB core in the same scenario. Both React Hook Form and VeeValidate ship official Valibot resolvers, so the swap touches only two lines.
npm install valibot @hookform/resolvers # React
npm install valibot @vee-validate/valibot # Vue
Replace zodResolver(signupSchema) with valibotResolver(signupSchema) on the React side, and toTypedSchema from @vee-validate/zod with the Valibot equivalent on the Vue side. Everything else in the components above stays untouched — that’s the benefit of keeping validation logic behind a resolver abstraction instead of hand-rolling checks in the component.
Step 11: Measure Bundle Size and Re-render Behavior
Run a production build for both projects and inspect the output chunk sizes.
npm run build
npx vite-bundle-visualizer # or check the dist/ output size directly
On a project this small, the difference between RHF and VeeValidate rounds to a few kilobytes and won’t show up in a Lighthouse score. The bigger practical difference is re-render behavior: React Hook Form’s uncontrolled-input model means typing in one field doesn’t re-render sibling fields, while VeeValidate’s Composition API refs are reactive by design, which is fine at this scale but worth profiling if you build a form with 40+ fields, like a tax form or an insurance application.
TypeScript Inference: What You Get for Free
One reason schema-first validation caught on in both ecosystems is that a single Zod schema gives you a fully typed form for free, with no separate interface to maintain. In the React project, useForm<SignupFormValues> takes its generic directly from z.infer<typeof signupSchema>, so register("fulName") — note the typo — fails to compile instead of silently registering a field that never existed. That single check catches a class of bug that used to only show up at runtime, usually reported by a confused user rather than a build tool. That inference chain got sturdier through 2026: React Hook Form added official TypeScript 6.0 support in version 7.75.0 back in May 2026 per Yarn’s release log, and version 7.86.0 that August introduced a type-safe getErrors helper so you can pull a typed errors object straight off the form state instead of narrowing errors manually.
VeeValidate’s toTypedSchema helper does the same job for the Vue side, wrapping the Zod schema so that defineField("plan") knows the field can only ever be "free", "pro", or "team", and flags any other string at compile time. The practical difference between the two setups is small: React’s generic goes on the useForm call itself, while Vue’s goes through the schema wrapper, but both end up with the same guarantee. If you add a field to the Zod schema and forget to wire it into the form component, TypeScript catches the unused-field mismatch in the React version through stricter linting rules, while VeeValidate surfaces it as a type error the moment you try to read a field that defineField doesn’t know about.
Accessibility: ARIA Attributes and Error Announcements
Neither React Hook Form nor VeeValidate adds ARIA attributes automatically, which is a common gap teams miss until an accessibility audit flags it. Screen readers need aria-invalid set on the input and aria-describedby pointing at the error message’s element ID, so the error gets announced the moment focus lands on a failed field. Wire it up explicitly in both versions.
{/* React Hook Form version */}
<input
{...register("email")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email.message}</p>
)}
The Vue version follows the same pattern, binding aria-invalid to a computed boolean derived from errors.email and giving the error paragraph a matching role="alert" so VoiceOver and NVDA announce it without the user needing to tab away and back. This is a five-minute addition in either framework, and it’s worth doing before you ship the form, not after an audit sends it back.
When to Choose Which: A Decision Matrix
Bundle size and star counts rarely decide a real project on their own. The table below breaks the decision down by the kind of form you’re actually building, since a marketing contact form and a 50-field enterprise back-office form have very different constraints.
| Scenario | Recommended Setup | Why |
|---|---|---|
| Small marketing form (2–4 fields) | React 19 native Actions, or plain VeeValidate with no schema | A full library adds more setup than the form needs |
| SaaS signup or settings form (5–15 fields) | React Hook Form + Zod, or VeeValidate + Zod | Cross-field rules and async checks justify a schema layer |
| Enterprise back-office form (30+ fields) | React Hook Form (uncontrolled model scales better) or VeeValidate with field-level watchers scoped carefully | Re-render cost matters at this scale; profile before shipping |
| No-JS or progressively enhanced form | Conform (React) or native HTML validation with Vue’s server-rendered forms | Both RHF and VeeValidate assume JavaScript is available |
| Form shared across a React and a Vue app | Either library, paired with the same Zod schema file | The schema, not the library, is what actually gets reused |
Step 12: Compare the Two Approaches Feature by Feature
| Feature | React Hook Form 7.88 | VeeValidate 4.15 |
|---|---|---|
| Input model | Uncontrolled (ref-based) | Reactive refs via Composition API |
| Validation timing | onBlur, onChange, onSubmit, onTouched (configurable) | Same four modes, configurable per field or form |
| Zod integration | Official resolver: @hookform/resolvers/zod | Official resolver: @vee-validate/zod |
| Async cross-field validation | Inline validate function per field | Async .refine() on the schema, awaited natively |
| DevTools | React Hook Form DevTools browser extension | Vue Devtools integration built in |
| Native framework competition | React 19 form Actions + useActionState cover simple cases | None — Vapor Mode doesn’t touch the validation layer |
| TypeScript inference | Full inference from Zod schema via generics | Full inference via toTypedSchema |
Neither library is objectively “better” here — they solved the same problem inside different reactivity models. If you’re already committed to React or Vue, this table mostly confirms that you won’t lose any capability by staying inside the ecosystem’s default choice. State of React 2025 survey data, as broken out in Strapi’s framework comparison, recorded 1,964 developers using React Hook Form against just 567 using TanStack Form, which tells you most teams have already made this call and aren’t looking back.
Common Pitfalls
These are the mistakes that show up most often when developers move from a toy form example to a real one with cross-field rules.
- Forgetting path on a Zod refine. Without it, the error attaches to the root object instead of a specific field, and neither RHF nor VeeValidate will know where to render it.
- Mixing controlled and uncontrolled patterns in React Hook Form. Calling setValue directly on a field that’s also bound with register causes state to fall out of sync. Use Controller for any input that needs external state control, like a custom date picker.
- Validating on every keystroke by default. Both libraries default to a lenient mode, but teams often flip to onChange mode for RHF or the equivalent immediate mode in VeeValidate, then wonder why users see a “required” error before they’ve typed a single character.
- Using a plain string schema for a number field. HTML inputs always return strings. Without Zod’s coerce.number(), a numeric field like teamSize fails validation even when the value looks correct on screen.
- Re-creating the Zod schema inside the component body. Defining signupSchema inline instead of importing it from a separate file causes an unnecessary resolver rebuild on every render in both frameworks, and makes the schema impossible to reuse server-side.
- Assuming VeeValidate’s async refine runs on every keystroke. By default it runs at the configured validation trigger, usually blur or submit, not on every change — testing it without triggering blur can make it look broken.
- Skipping the flatten call on server-side Zod errors. A raw ZodError object isn’t JSON-serializable the way a client expects; always flatten or format it before sending it back in an API response.
Output Examples: What a Successful Run Looks Like
When the React form submits successfully with valid data, the console should show:
submitted {
fullName: 'Jamie Torres',
email: '[email protected]',
password: 'correcthorsebattery',
confirmPassword: 'correcthorsebattery',
plan: 'team',
teamSize: 4
}
A failed submission with mismatched passwords instead renders inline under the confirm-password field, with the submit button re-enabled and no console output, since handleSubmit blocks the callback until validation passes. The Vue version produces the identical shape in its console log, since both forms ultimately serialize through the same signupSchema type.
Troubleshooting
Work through these in order if either form isn’t behaving as expected.
- “Cannot find module ‘@hookform/resolvers/zod'” — the resolvers package ships separate subpath exports per validator; run npm install @hookform/resolvers and import from the /zod subpath exactly as shown, not the package root.
- Zod errors show up as [object Object] in the UI — you’re rendering errors.email instead of errors.email.message in React, or errors.email in Vue without accessing VeeValidate’s string-formatted version. VeeValidate’s errors object already returns strings; RHF’s returns an error object per field.
- Async email check runs twice per keystroke — this happens when both onChange mode and an async validate function are combined without debouncing. Add a debounce wrapper around the network call itself, not around the validation trigger.
- toTypedSchema throws a type error on the refine chain — VeeValidate’s typed-schema helper needs the final Zod type to still expose parse and safeParse methods; wrapping a refine in certain custom generics breaks that. Keep the exported schema as a direct object-refine chain, not reassigned through an intermediate type alias.
- Team size field validates even when plan isn’t “team” — the conditional refine in the schema still runs against undefined, which is fine, but forgetting optional() on teamSize causes it to fail as “required” even for free-plan users. Double-check the field is marked optional at the schema level.
- Vite dev server won’t start after installing vee-validate — usually a stale node_modules from a prior Vue 2 or Vuex-based scaffold. Delete node_modules and package-lock.json, then reinstall.
- Form submits with stale values in React — you spread register() onto a custom component instead of a native input element. Uncontrolled ref-based inputs require RHF’s Controller wrapper for any non-native form element.
- Passwords never trigger the “do not match” error — the refine for password matching must sit on the outer object schema, not inside a nested field schema; refines on inner schemas can’t see sibling fields.
Advanced Tips
Once the base forms work, a few patterns separate a working demo from a production-ready setup.
First, split large schemas into composable pieces with Zod’s merge or extend methods so a multi-step wizard can validate each step independently before combining everything into the final submit schema. Both RHF and VeeValidate support partial validation through this pattern without extra library code.
Second, if you’re building a form-heavy React app on Next.js or Remix, look at Conform, a newer server-action-first form library that reports roughly 400,000 weekly downloads and is designed specifically to pair progressive enhancement with the same schema-first philosophy this tutorial uses. It’s worth evaluating alongside RHF once your forms need to work without JavaScript enabled.
Third, watch TanStack Form’s v2 line, announced in alpha in August 2026. It’s a framework-agnostic rewrite that could eventually let a single form-state layer serve both a React and a Vue frontend from shared logic — not stable enough for production yet, but worth tracking if you maintain both stacks in the same organization.
Fourth, for very large forms, profile actual re-renders with React DevTools’ profiler or Vue DevTools’ component inspector before assuming either library has a performance problem. Most perceived slowness in a 2026 form comes from unnecessary parent re-renders triggered by state living too high in the component tree, not from the validation library itself.
The Complete Working Project
At this point you have two fully functional projects: react-forms-demo and vue-forms-demo, each with a signup form that validates five fields, a cross-field password check, a conditional team-size field, async email validation, server-error handling, and a shared Zod schema reused on a mock API route. The final directory structure for the React project looks like this:
react-forms-demo/
├── src/
│ ├── schema.ts
│ ├── SignupForm.tsx
│ ├── api/
│ │ └── signup.ts
│ └── App.tsx
├── package.json
└── vite.config.ts
The Vue project mirrors it with SignupForm.vue in place of the .tsx file and an identical schema.ts. Both projects share the exact same validation rules, which means you now have a template you can copy for the next form in either app without re-deriving the password or plan logic from scratch.
Migrating an Existing Formik or Vuex-Based Form
If you’re arriving at this tutorial because a legacy Formik or Vuex form needs an update rather than a greenfield project, the migration path is more mechanical than it looks. Formik’s values, errors, and handleSubmit map almost directly onto React Hook Form’s equivalents, and most of the actual work is converting Formik’s Yup schemas to Zod, since the two validation DSLs share similar chaining syntax but differ in how they express optional fields and custom refinements. Budget roughly 20-30 minutes per form for a straightforward conversion, more if the original form leans on Formik’s FieldArray for dynamic lists, since RHF’s useFieldArray hook has a different API shape and needs its own pass.
On the Vue side, older codebases built directly on Vuex for form state (rather than a dedicated validation library) require more restructuring, since Vuex was never a form library to begin with — it’s a general state container that teams sometimes repurposed for form data. Moving that logic into VeeValidate means extracting the validation rules that were likely scattered across Vuex actions and mutations into a single Zod schema, then replacing direct store.commit() calls with defineField bindings. It’s more work up front, but it collapses form logic that used to span two files (a component and a Vuex module) into one component plus one schema file, which pays for itself the next time someone has to debug a validation rule at 6 p.m. on a Friday.
Choosing Between React Hook Form and VeeValidate for Your Next Project
If you’re picking a framework from scratch purely for form-heavy work, this comparison won’t tip the scale on its own — both libraries handle the same validation patterns with comparable code volume, and both ship official Zod and Valibot resolvers. The decision should follow whichever framework your team already knows. What changes the calculus is scale: React Hook Form’s smaller bundle and uncontrolled-input model give it a slight edge in apps with very large or deeply nested forms, while VeeValidate’s tighter integration with Vue’s reactivity system means less mental overhead if your app is already built around Vue’s Composition API patterns elsewhere.
Either way, the schema-first approach with React Hook Form or VeeValidate pays off the moment you need to reuse validation logic outside the browser. That’s the part of this setup worth carrying into 2027, regardless of which frontend framework wins your next project.
Frequently Asked Questions
Is React Hook Form still the default choice for React forms in 2026?
Yes. State of React 2025 survey data, as broken out in Strapi’s framework comparison, counted 1,964 developers using React Hook Form against 567 using TanStack Form, putting RHF well ahead in the form-handling category.
Does React 19’s native form Actions API replace React Hook Form?
Not for complex forms. Native Actions and useActionState handle simple submission and pending state well, but they don’t give you field-level validation, cross-field rules, or the devtools RHF provides, so most teams keep RHF for anything beyond a two-field form.
Is VeeValidate 5 ready for production yet?
No, as of September 2026 VeeValidate 5 is still in beta. vee-validate first shipped 5.0.0-beta.0 in August 2025 with first-class Standard Schema support — GitHub’s release notes name Zod, Valibot, and Yup as the supported schema ecosystems — and followed up with 5.0.0-beta.1 in March 2026, which Snyk’s package listing still shows as the newest beta build. Use the stable 4.15.1 line, released June 2025, for production projects until a final 5.0 release lands.
Should I use Zod or Valibot for a new project?
Zod has the larger ecosystem, more resolvers, and more community examples, so it’s the safer default. Choose Valibot only if bundle size is a hard constraint — its tree-shaken schemas can run under 2 KB gzipped against Zod’s 5–6 KB core in equivalent scenarios.
Does Vue 3.6’s Vapor Mode change how VeeValidate works?
No. Vapor Mode changes Vue’s rendering strategy, not its Composition API surface, so VeeValidate’s useForm and useField functions work identically whether or not a component opts into Vapor Mode.
Can I share one Zod schema between a React frontend and a Vue frontend?
Yes, and that’s one of the strongest arguments for schema-first validation. Since Zod schemas are framework-agnostic TypeScript, the same schema.ts file used in this tutorial works unmodified as an import in either the React or the Vue project, or in a Node or Deno server route.
Is Formik still worth learning in 2026?
Mostly for maintaining legacy codebases. Formik still sees millions of weekly downloads and roughly 34,300 GitHub stars, but new-project adoption has shifted toward React Hook Form and, in TypeScript-heavy teams, TanStack Form.
What’s the actual bundle-size difference between the two approaches in this tutorial?
React Hook Form plus Zod’s v4 core adds roughly 15–17 KB gzipped total. VeeValidate’s full bundle plus the same Zod core lands closer to 35–40 KB gzipped, though switching to VeeValidate’s minimal build or Valibot narrows that gap considerably.





