Next-Gen Forms: Build Type-Safe, Validated Forms with TanStack Form and Zod
A deep dive into building robust, type-safe, and validated forms using the powerful combination of TanStack Form and Zod. Learn how to eliminate boilerplate, handle complex validation, and ensure data integrity in your web applications.
Building forms is an essential part of almost every web application. However, it can often be a source of frustration, leading to boilerplate code, confusing state management, and an increased risk of data-related bugs. Developers frequently find themselves juggling form state, validation logic, and error handling, often duplicating work and creating brittle user interfaces.
This is where a modern, type-safe approach becomes a game-changer. By combining the declarative power of TanStack Form with the robust validation capabilities of Zod, we can eliminate this complexity and build forms that are not only easier to maintain but also a joy to work with. In this tutorial, we’ll walk through a practical example of building a user registration form that is fully type-safe, validated on both the client and server (conceptually), and provides an excellent user experience.
Why TanStack Form and Zod? 🤔
Before we dive into the code, let’s understand why this combination is so powerful.
- TanStack Form: Part of the TanStack ecosystem (like TanStack Query and TanStack Table), it’s a headless, framework-agnostic form library. It doesn’t dictate your UI; instead, it provides the core logic for managing form state, handling submissions, and tracking validation. This “headless” approach gives you complete control over your form’s appearance and behavior, while the library handles the heavy lifting behind the scenes.
- Zod: A TypeScript-first schema declaration and validation library. It allows you to define the shape of your data with schemas. Zod is not just a validator; it’s a schema-first library. This means your schema defines the data’s type, and that same schema is used to validate it. This powerful synergy eliminates the need for separate type definitions and validation logic, ensuring your form data always matches what your application expects.
“The combination of TanStack Form and Zod is like having a perfect dance partner. TanStack Form handles the choreography of the form state, and Zod ensures every step is perfectly executed and in sync with the data’s expected structure.”
Getting Started: Setting Up Our Project 🚀
For this tutorial, we’ll use a simple React application, but the core concepts are applicable to any framework supported by TanStack Form (React, Vue, Solid, Svelte, etc.).
First, let’s install the necessary packages:
npm install @tanstack/react-form zod
Next, let’s define our form’s data structure using Zod. We’ll create a schema for a simple user registration form with username, email, and password.
Step 1: Defining the Zod Schema 👨💻
The first step is to define a Zod schema that describes the shape of our form data and the validation rules for each field.
import { z } from 'zod';
export const registrationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters long')
.max(20, 'Username cannot exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters long')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
});
// We can infer the TypeScript type directly from the schema
export type RegistrationFormType = z.infer<typeof registrationSchema>;
Notice how clean and declarative this is. We’ve defined all our validation rules and, thanks to Zod’s infer feature, we’ve also created a TypeScript type RegistrationFormType that is guaranteed to match our schema. This is the source of our type safety!
Step 2: Creating the Form Component ✍️
Now, let’s create a React component that uses useForm from @tanstack/react-form.
import React from 'react';
import { useForm, useField } from '@tanstack/react-form';
import { registrationSchema } from './registrationSchema';
export function RegistrationForm() {
const form = useForm({
defaultValues: {
username: '',
email: '',
password: '',
},
validatorAdapter: (validator) => ({
validate: async (value, fieldMeta) => {
try {
// Use the Zod schema to parse and validate the field
await registrationSchema.pick({ [fieldMeta.name]: true }).parseAsync({ [fieldMeta.name]: value });
return undefined; // No error
} catch (error) {
if (error instanceof z.ZodError) {
// Return the Zod error message
return error.errors[0].message;
}
return 'An unknown error occurred';
}
},
}),
});
const handleSubmit = (values: typeof form.state.values) => {
// This function is only called if all fields pass validation
console.log('Form submitted successfully!', values);
// Here, you would typically make an API call
};
return (
<form.Provider>
<form onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}>
<form.Field
name="username"
children={(field) => (
<div>
<label htmlFor={field.name}>Username</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors ? (
<em className="error">{field.state.meta.errors[0]}</em>
) : null}
</div>
)}
/>
{/* Similar fields for email and password... */}
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Register'}
</button>
)}
/>
</form>
</form.Provider>
);
}
Analyzing the Code 🧠
Let’s break down the key parts of our form component:
useForm: This is the core hook. We pass itdefaultValuesto initialize our form state.validatorAdapter: This is the magic! We provide avalidatefunction that takes a value and a field name. Inside this function, we use our Zod schema (registrationSchema.pick) to validate only the current field. IfparseAsyncthrows an error, we catch it and return the error message. This tightly couples our validation to our Zod schema.form.Field: This is a headless component provided by TanStack Form. We give it anamethat corresponds to a key in our Zod schema. Thechildrenprop is a render function that receives afieldobject. This object contains all the necessary state (field.state.value,field.state.meta.errors) and handlers (field.handleChange,field.handleBlur) for that specific input.form.Subscribe: A powerful tool to selectively re-render parts of the UI based on form state. Here, we subscribe tocanSubmitandisSubmittingto manage the submit button’s disabled state, ensuring the UI is always in sync with the form’s logic without unnecessary re-renders.
Advanced Concepts: Beyond the Basics 💡
This example just scratches the surface. Here are some advanced techniques you can explore:
- Dependent Fields: What if you need to validate a field based on another? For example, a “Confirm Password” field. Zod’s
refinemethod is perfect for this.const registrationSchema = z.object({ // ... other fields password: z.string().min(8), confirmPassword: z.string().min(8), }).refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], // This tells Zod which field to attach the error to }); - Asynchronous Validation: Need to check if a username is already taken by making an API call? Zod’s
superRefineallows for asynchronous validation within the schema itself. TanStack Form can then manage the loading and error states for you. - Server-Side Validation: The beauty of this approach is that you can use the exact same Zod schema on your backend (if it’s a Node.js/TypeScript environment). This guarantees that the data you receive on the server has the exact same structure and validity as what was checked on the client, eliminating data discrepancies and security vulnerabilities.
Conclusion: The Future of Form Building ✅
By adopting a type-safe, schema-first approach with TanStack Form and Zod, we move away from the tedious, error-prone world of manual form state management. We gain:
- Elimination of boilerplate: No more
useStatefor every input and manual validation checks. - Guaranteed type safety: Your form data will always match its expected type, reducing runtime bugs.
- Superior user experience: Real-time validation and clear error messages are easy to implement.
- Code reusability: The same validation schema can be used on the client and server.
- Flexibility: The headless nature of TanStack Form gives you total control over your UI.
Take control of your forms and elevate your application’s data integrity. The combination of TanStack Form and Zod is more than just a toolset; it’s a paradigm shift towards building more robust, reliable, and maintainable web applications.