Welcome back, intrepid developer! In our journey through the TanStack ecosystem, we’ve explored powerful tools for managing server state with Query and displaying complex data with Table. Now, it’s time to tackle another cornerstone of web applications: forms. Forms are how users interact with our applications, submit data, and provide input. Building them can often be a repetitive and error-prone task, especially when dealing with validation, state management, and ensuring a great user experience.
In this chapter, we’ll dive deep into TanStack Form, a powerful, headless library designed to simplify form creation while emphasizing type safety and accessibility. You’ll learn how to construct robust forms that are a joy to develop and a breeze for users to interact with. We’ll cover everything from basic setup and field management to advanced validation using Zod, ensuring your forms are not just functional, but also resilient and user-friendly.
Before we begin, a basic understanding of React (or your preferred framework) and TypeScript is beneficial. If you’ve been following along, you’re already familiar with the TanStack philosophy of “headless” libraries, which gives us maximum control over our UI. Get ready to transform your form development experience!
Core Concepts of TanStack Form
Building effective forms means more than just throwing some <input> tags on a page. It involves managing input values, handling user interactions (like blurring and changing fields), validating data, displaying errors, and submitting information. TanStack Form provides a structured, type-safe way to manage all these aspects.
What is TanStack Form? The Headless Approach
Just like TanStack Table, TanStack Form is a headless library. This means it provides all the core logic for form management—state, validation, submission—without dictating any UI. You bring your own components (like <input>, <select>, custom UI elements), and TanStack Form provides the hooks and utilities to connect them to its powerful engine.
Why headless?
- Maximum Flexibility: You have complete control over styling, markup, and accessibility.
- Framework Agnostic: While we’ll use
@tanstack/react-formfor React, the core logic in@tanstack/form-corecan be adapted to any framework. - Separation of Concerns: Your UI components stay clean, focusing solely on presentation, while form logic lives in the TanStack Form hooks.
Form State Management: The Brain Behind Your Forms
At its heart, TanStack Form manages a comprehensive state for your entire form. Think of it as a central nervous system keeping track of:
- Field Values: The current data in each input.
- Touched State: Whether a user has interacted with a field (useful for showing errors only after interaction).
- Dirty State: Whether a field’s value has changed from its initial value.
- Validation Errors: Any issues identified by your validation rules.
- Submission Status: Whether the form is currently submitting, has been submitted, or encountered an error during submission.
This detailed state allows you to build dynamic forms that react intelligently to user input.
Schema Validation with Zod: Ensuring Data Integrity
One of the standout features of TanStack Form is its seamless integration with validation libraries, particularly Zod. Zod is a TypeScript-first schema declaration and validation library. This means you define your form’s expected data structure and validation rules using Zod, and TanStack Form uses this schema to automatically validate your fields.
Why Zod?
- Type Safety: Zod schemas are inferred as TypeScript types, ensuring that your form data always matches the expected shape, from client-side validation to API calls. No more guessing what type
formData.emailis! - Powerful Validation Primitives: Zod offers a rich set of built-in validators for strings, numbers, dates, arrays, and more, along with options for custom validation logic.
- Developer Experience: It’s intuitive to use and provides clear error messages.
Field Abstraction: Managing Individual Inputs
TanStack Form doesn’t just manage the whole form; it also provides an elegant abstraction for individual form fields. Instead of directly managing onChange and value for every input, you use a Field component or hook (depending on your framework adapter). This Field component connects your UI input to the form’s central state, providing all the necessary props and state for that specific input.
Accessibility Focus: Building Inclusive Forms
By providing a headless API, TanStack Form empowers you to build highly accessible forms. You have full control over:
- Semantic HTML: Use native
<label>,<input>,<button>elements as appropriate. - ARIA Attributes: Easily add
aria-describedby,aria-invalid,aria-liveto enhance screen reader experiences, especially for error messages. - Focus Management: Control tab order and focus as needed.
This flexibility is crucial for creating applications that everyone can use.
The Form Data Flow
Let’s visualize how data moves through a TanStack Form.
Figure 9.1: TanStack Form Data Flow
- User Interaction: A user types into an input or blurs a field.
- Field Component/Hook: The
Fieldcomponent (or its hook equivalent) captures this event. - Form State: The event updates the central form state.
- Zod Schema Validation: The entire form (or just the touched field, depending on configuration) is validated against the Zod schema.
- Valid Data: If valid, the form state is updated, and the UI reflects the new, valid data.
- Invalid Data: If invalid, the form state is updated with validation errors, which are then displayed in the UI.
- Form Submission: When the user attempts to submit, the form checks its overall validity.
- API Call: If valid, the
onSubmithandler is triggered, typically making an API call (perhaps using TanStack Query, as we learned in Chapter 7). - Update UI: The UI is updated based on the API response (e.g., showing a success message or server-side errors).
Step-by-Step Implementation: Building Our First Type-Safe Form
Let’s get our hands dirty and build a simple user profile form using React, TypeScript, TanStack Form, and Zod.
Step 9.1: Project Setup and Installation
First, ensure you have a React project set up. If not, you can quickly create one:
# If you need to create a new React project
npm create vite@latest my-tanstack-form-app -- --template react-ts
cd my-tanstack-form-app
npm install
Now, let’s install the necessary TanStack Form packages and Zod. As of January 2026, we’ll use the latest stable versions.
npm install @tanstack/react-form@latest @tanstack/form-core@latest zod@latest
@tanstack/react-form: The React adapter for TanStack Form.@tanstack/form-core: The headless core logic, whichreact-formbuilds upon.zod: Our chosen schema validation library.
Step 9.2: Defining Our Form Schema with Zod
Before writing any UI code, let’s define the shape of our form data and its validation rules using Zod. Create a new file, say src/schemas/userProfileSchema.ts:
// src/schemas/userProfileSchema.ts
import { z } from 'zod';
export const userProfileSchema = z.object({
firstName: z
.string()
.min(1, 'First name is required')
.max(50, 'First name cannot exceed 50 characters'),
lastName: z
.string()
.min(1, 'Last name is required')
.max(50, 'Last name cannot exceed 50 characters'),
email: z
.string()
.email('Invalid email address')
.min(1, 'Email is required'),
age: z
.number()
.min(18, 'You must be at least 18 years old')
.max(120, 'Age cannot exceed 120')
.optional(), // Making age optional
});
// Infer the TypeScript type from the Zod schema
export type UserProfileForm = z.infer<typeof userProfileSchema>;
Explanation:
- We import
zfromzod. z.object()defines the overall shape of our form data.- For each field (
firstName,lastName,email,age), we define its type and validation rules using Zod’s chainable methods (e.g.,.string(),.min(),.email(),.number(),.optional()). z.infer<typeof userProfileSchema>is a powerful Zod feature that automatically infers a TypeScript type (UserProfileForm) from our schema. This type will ensure our form data is always type-safe.
Step 9.3: Creating a Basic Form Component
Now, let’s create our first form component. We’ll start with just the firstName field.
Open src/App.tsx and replace its content with the following:
// src/App.tsx
import React from 'react';
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/form-core'; // Import the Zod adapter for form-core
import { userProfileSchema, UserProfileForm } from './schemas/userProfileSchema';
function App() {
// 1. Initialize the form using useForm hook
const form = useForm<UserProfileForm>({
defaultValues: {
firstName: '',
lastName: '',
email: '',
age: undefined, // Use undefined for optional number fields
},
// 2. Attach our Zod schema for validation
validator: zodValidator,
// Provide the Zod schema directly
// This is how TanStack Form knows how to validate against our schema
onSubmit: async ({ value }) => {
// Handle form submission logic here
console.log('Form submitted with values:', value);
alert(`Form submitted! Check console for data.`);
// In a real app, you'd send this data to a server, perhaps using TanStack Query!
},
});
return (
<div style={{ maxWidth: '600px', margin: '50px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h1>User Profile</h1>
{/* 3. Render the form element and attach onSubmit handler */}
<form
onSubmit={(e) => {
e.preventDefault(); // Prevent default browser form submission
e.stopPropagation(); // Stop event propagation
form.handleSubmit(); // Trigger TanStack Form's submission logic
}}
>
{/* 4. Use the form.Field component for each input */}
<div>
<form.Field
name="firstName" // IMPORTANT: Must match a key in your Zod schema
children={(field) => ( // The children prop receives the field object
<>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value} // Connect input value to field state
onBlur={field.handleBlur} // Handle blur event
onChange={(e) => field.handleChange(e.target.value)} // Handle change event
/>
{/* 5. Display validation errors if any */}
{field.state.meta.errors ? (
<em style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
{/* We'll add more fields here shortly! */}
<button type="submit" disabled={form.state.isSubmitting}>
{form.state.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
</div>
);
}
export default App;
Explanation (Incremental Build-Up):
import { useForm } from '@tanstack/react-form';: We import the main hook for creating forms in React.import { zodValidator } from '@tanstack/form-core';: We import the utility to integrate Zod with TanStack Form’s validation system.import { userProfileSchema, UserProfileForm } from './schemas/userProfileSchema';: We bring in our schema and its inferred type.const form = useForm<UserProfileForm>({...}):- This is the heart of our form. We call
useForm, passing ourUserProfileFormtype to ensure type safety throughout. defaultValues: An object setting the initial values for all our form fields. It’s crucial this matches theUserProfileFormtype.validator: zodValidatorandvalidatorAdapter: () => ({ validator: userProfileSchema }): This tells TanStack Form to use Zod for validation and provides our specificuserProfileSchema.onSubmit: An asynchronous function that will be called when the form is successfully submitted (i.e., all validation passes). It receives an object containing the validatedvalueof the form.
- This is the heart of our form. We call
<form onSubmit={...}>:- We render a standard HTML
<form>element. e.preventDefault()ande.stopPropagation(): These are essential to prevent the browser’s default form submission behavior, allowing TanStack Form to take control.form.handleSubmit(): This method triggers the internal validation andonSubmitlogic of TanStack Form.
- We render a standard HTML
<form.Field name="firstName" ... />:- This is the core component for managing individual fields.
name="firstName": CRITICAL! This prop must exactly match one of the keys in youruserProfileSchema. This is how TanStack Form links the UI element to the specific part of your form state and validation rules.children={(field) => (...)}: Thechildrenprop uses a render prop pattern. It provides afieldobject which contains all the necessary state and handlers for that specific input.- Inside
children:<label htmlFor={field.name}>: Good practice for accessibility, linking the label to the input.<input id={field.name} name={field.name} ... />: Our actual HTML input element.value={field.state.value}: Binds the input’s value to the current state managed by TanStack Form for this field.onBlur={field.handleBlur}: Tells TanStack Form when the user leaves the input, which can trigger validation based on configuration.onChange={(e) => field.handleChange(e.target.value)}: Updates the field’s value in the form state as the user types.{field.state.meta.errors ? ... : null}: This conditional rendering displays any validation errors associated with thefirstNamefield.field.state.meta.errorsis an array of strings.
<button type="submit" disabled={form.state.isSubmitting}>:- A standard submit button.
disabled={form.state.isSubmitting}: Disables the button while the form is submitting, preventing duplicate submissions.
If you run your app (npm run dev), you’ll see a form with a single “First Name” field. Try typing in it, then clearing it and blurring the field. You should see the “First name is required” error appear!
Step 9.4: Adding More Fields
Now, let’s complete our form by adding the lastName, email, and age fields.
Modify src/App.tsx by adding the new form.Field blocks after the firstName field:
// src/App.tsx (continued from previous step)
// ... (imports and useForm hook remain the same)
function App() {
const form = useForm<UserProfileForm>({
defaultValues: {
firstName: '',
lastName: '',
email: '',
age: undefined,
},
validator: zodValidator,
onSubmit: async ({ value }) => {
console.log('Form submitted with values:', value);
alert(`Form submitted! Check console for data.`);
},
});
return (
<div style={{ maxWidth: '600px', margin: '50px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h1>User Profile</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
{/* First Name Field (from previous step) */}
<div style={{ marginBottom: '15px' }}>
<form.Field
name="firstName"
children={(field) => (
<>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="text" // Always specify type for inputs
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
{/* NEW: Last Name Field */}
<div style={{ marginBottom: '15px' }}>
<form.Field
name="lastName" // Matches schema key
children={(field) => (
<>
<label htmlFor={field.name}>Last Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="text"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
{/* NEW: Email Field */}
<div style={{ marginBottom: '15px' }}>
<form.Field
name="email" // Matches schema key
children={(field) => (
<>
<label htmlFor={field.name}>Email:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="email" // Use type="email" for better UX and basic browser validation
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
{/* NEW: Age Field */}
<div style={{ marginBottom: '20px' }}>
<form.Field
name="age" // Matches schema key
children={(field) => (
<>
<label htmlFor={field.name}>Age:</label>
{/* Note: value for number inputs should be handled carefully.
An empty string for undefined/null is common.
We'll convert to number on change.
*/}
<input
id={field.name}
name={field.name}
value={field.state.value ?? ''} // Display empty string if age is undefined
onBlur={field.handleBlur}
onChange={(e) => {
// Convert input string to a number or undefined
const val = e.target.value;
field.handleChange(val === '' ? undefined : Number(val));
}}
type="number" // Use type="number" for numeric input
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<button type="submit" disabled={form.state.isSubmitting}>
{form.state.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
</div>
);
}
export default App;
Explanation:
- We’ve added three more
form.Fieldblocks, one forlastName, one foremail, and one forage. - Each
nameprop correctly maps to a key in ouruserProfileSchema. - Important for
age(number input): HTMLinput type="number"elements return theirvalueas a string. Our Zod schema expects anumber | undefined. We handle this conversion in theonChangehandler:field.state.value ?? '': Displays an empty string in the input iffield.state.valueisundefined(which is ourdefaultValuesfor optionalage).val === '' ? undefined : Number(val): Converts an empty string back toundefinedor a non-empty string to anumber. This ensures our form state correctly holds anumber | undefinedforage.
- We’ve added basic inline styles for better readability.
Now, interact with the form. You’ll see type-safe validation in action:
- Try submitting an empty form.
- Enter an invalid email address.
- Enter an age less than 18 or greater than 120.
- Notice how errors appear only after you’ve blurred a field, providing a better user experience.
Step 9.5: Integrating with TanStack Query for Submission (Optional but Recommended)
While onSubmit handles the form data, in a real-world application, you’d typically send this data to a backend API. This is where TanStack Query (from Chapter 7) shines!
To integrate, you’d define a mutation using useMutation and call it within your onSubmit handler.
First, let’s assume you have TanStack Query set up (as covered in Chapter 7). If not, you’d need to install @tanstack/react-query and wrap your App component with a QueryClientProvider.
# If you don't have TanStack Query installed
npm install @tanstack/react-query@latest
Then, modify src/App.tsx to include useMutation:
// src/App.tsx
import React from 'react';
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/form-core';
import { userProfileSchema, UserProfileForm } from './schemas/userProfileSchema';
import { useMutation, useQueryClient } from '@tanstack/react-query'; // Import Query hooks
// Mock API call function
const updateUserProfile = async (data: UserProfileForm): Promise<UserProfileForm> => {
console.log('Simulating API call with:', data);
return new Promise((resolve) => {
setTimeout(() => {
// Simulate success
console.log('API call successful!');
resolve({ ...data }); // Return the submitted data
}, 1500);
});
};
function App() {
const queryClient = useQueryClient(); // Get the query client instance
// Define a mutation for updating the user profile
const mutation = useMutation({
mutationFn: updateUserProfile,
onSuccess: (data) => {
console.log('Mutation successful! Received:', data);
// Invalidate relevant queries to refetch data, e.g., user profile data
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
alert('Profile updated successfully!');
},
onError: (error) => {
console.error('Mutation failed:', error);
alert('Failed to update profile. Please try again.');
},
});
const form = useForm<UserProfileForm>({
defaultValues: {
firstName: '',
lastName: '',
email: '',
age: undefined,
},
validator: zodValidator,
onSubmit: async ({ value }) => {
// Use the mutation to submit the form data
mutation.mutate(value);
},
});
return (
<div style={{ maxWidth: '600px', margin: '50px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h1>User Profile</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
{/* ... (all form.Field components remain the same) ... */}
<div style={{ marginBottom: '15px' }}>
<form.Field
name="firstName"
children={(field) => (
<>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="text"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<form.Field
name="lastName"
children={(field) => (
<>
<label htmlFor={field.name}>Last Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="text"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<form.Field
name="email"
children={(field) => (
<>
<label htmlFor={field.name}>Email:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="email"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<form.Field
name="age"
children={(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value ?? ''}
onBlur={field.handleBlur}
onChange={(e) => {
const val = e.target.value;
field.handleChange(val === '' ? undefined : Number(val));
}}
type="number"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<button type="submit" disabled={form.state.isSubmitting || mutation.isPending}>
{form.state.isSubmitting || mutation.isPending ? 'Saving...' : 'Save Profile'}
</button>
</form>
{mutation.isError && <p style={{ color: 'red' }}>Error: {mutation.error?.message}</p>}
{mutation.isSuccess && <p style={{ color: 'green' }}>Profile saved!</p>}
</div>
);
}
export default App;
Explanation:
updateUserProfile: A mock function simulating an API call. It returns aPromisethat resolves after 1.5 seconds.useQueryClient(): We get an instance of theQueryClientto interact with TanStack Query’s cache.useMutation:mutationFn: updateUserProfile: This is the function that performs the actual data submission.onSuccess: Callback when the mutation successfully completes. WeinvalidateQueriesfor['userProfile']to ensure any cached user profile data is marked as stale and refetched, keeping our UI consistent with the backend.onError: Callback if the mutation fails.
onSubmithandler inuseForm: Instead of just logging, we now callmutation.mutate(value)to trigger our TanStack Query mutation.- Submit Button: The
disabledprop now also checksmutation.isPendingto ensure the button is disabled both during client-side form submission (validation) and during the actual API call. We also added UI feedback formutation.isErrorandmutation.isSuccess.
This integration demonstrates how seamlessly TanStack libraries work together, providing a complete solution for frontend data management.
Mini-Challenge: Add a Checkbox and Custom Zod Validation
It’s your turn! Expand our user profile form with a new field and a custom validation rule.
Challenge:
- Add a new field called
newsletterOptIn(boolean) to theuserProfileSchema.- It should be optional, with a default of
false.
- It should be optional, with a default of
- Add a new field called
confirmEmail(string) to theuserProfileSchema.- It should be required.
- Add a custom Zod validation rule to the entire schema that ensures
confirmEmailexactly matches theemailfield. This is a common pattern for “confirm password” or “confirm email” fields. - Render a checkbox for
newsletterOptInand a text input forconfirmEmailin yourApp.tsx.- Remember how to handle boolean values for checkboxes.
- Display validation errors for
confirmEmailand a general form error if the emails don’t match.
Hint:
- For checkbox
onChange, you’ll likely usee.target.checkedfor the boolean value. - For cross-field validation with Zod, look into the
.refine()method on thez.object()schema. It allows you to add custom validation logic that checks conditions across multiple fields.
What to observe/learn:
- How to extend your Zod schema with new fields and custom validation.
- How to handle different input types (like checkboxes) with
form.Field. - How to display global form errors (from
.refine()validation).
Take your time, experiment, and refer back to the examples!
Click for Solution (if you get stuck!)
// src/schemas/userProfileSchema.ts (Solution)
import { z } from 'zod';
export const userProfileSchema = z.object({
firstName: z
.string()
.min(1, 'First name is required')
.max(50, 'First name cannot exceed 50 characters'),
lastName: z
.string()
.min(1, 'Last name is required')
.max(50, 'Last name cannot exceed 50 characters'),
email: z
.string()
.email('Invalid email address')
.min(1, 'Email is required'),
confirmEmail: z
.string()
.min(1, 'Confirm email is required'),
age: z
.number()
.min(18, 'You must be at least 18 years old')
.max(120, 'Age cannot exceed 120')
.optional(),
newsletterOptIn: z.boolean().default(false), // New field
})
.refine((data) => data.email === data.confirmEmail, {
message: 'Emails do not match',
path: ['confirmEmail'], // Point the error to the confirmEmail field
});
export type UserProfileForm = z.infer<typeof userProfileSchema>;
// src/App.tsx (Solution)
import React from 'react';
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/form-core';
import { userProfileSchema, UserProfileForm } from './schemas/userProfileSchema';
import { useMutation, useQueryClient } from '@tanstack/react-query';
const updateUserProfile = async (data: UserProfileForm): Promise<UserProfileForm> => {
console.log('Simulating API call with:', data);
return new Promise((resolve, reject) => {
setTimeout(() => {
// Simulate a server-side error if email and confirmEmail don't match
if (data.email !== data.confirmEmail) {
reject(new Error("Server-side: Emails still don't match!"));
} else {
console.log('API call successful!');
resolve({ ...data });
}
}, 1500);
});
};
function App() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateUserProfile,
onSuccess: (data) => {
console.log('Mutation successful! Received:', data);
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
alert('Profile updated successfully!');
},
onError: (error) => {
console.error('Mutation failed:', error);
alert('Failed to update profile. Please try again. Error: ' + error.message);
},
});
const form = useForm<UserProfileForm>({
defaultValues: {
firstName: '',
lastName: '',
email: '',
confirmEmail: '', // New default value
age: undefined,
newsletterOptIn: false, // New default value
},
validator: zodValidator,
onSubmit: async ({ value }) => {
mutation.mutate(value);
},
});
return (
<div style={{ maxWidth: '600px', margin: '50px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h1>User Profile</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<div style={{ marginBottom: '15px' }}>
<form.Field
name="firstName"
children={(field) => (
<>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="text"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<form.Field
name="lastName"
children={(field) => (
<>
<label htmlFor={field.name}>Last Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="text"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<form.Field
name="email"
children={(field) => (
<>
<label htmlFor={field.name}>Email:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="email"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
{/* NEW: Confirm Email Field */}
<div style={{ marginBottom: '15px' }}>
<form.Field
name="confirmEmail"
children={(field) => (
<>
<label htmlFor={field.name}>Confirm Email:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="email"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<form.Field
name="age"
children={(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value ?? ''}
onBlur={field.handleBlur}
onChange={(e) => {
const val = e.target.value;
field.handleChange(val === '' ? undefined : Number(val));
}}
type="number"
/>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
{/* NEW: Newsletter Opt-in Checkbox */}
<div style={{ marginBottom: '20px' }}>
<form.Field
name="newsletterOptIn"
children={(field) => (
<>
<input
id={field.name}
name={field.name}
checked={field.state.value} // Use checked for boolean
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.checked)} // Use e.target.checked
type="checkbox"
/>
<label htmlFor={field.name} style={{ marginLeft: '5px' }}>Opt-in to Newsletter</label>
{field.state.meta.errors ? (
<em style={{ color: 'red', display: 'block' }}>{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
/>
</div>
{/* Display general form errors (e.g., from .refine()) */}
{form.state.meta.errors?.length ? (
<div style={{ color: 'red', marginBottom: '10px' }}>
{form.state.meta.errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
) : null}
<button type="submit" disabled={form.state.isSubmitting || mutation.isPending}>
{form.state.isSubmitting || mutation.isPending ? 'Saving...' : 'Save Profile'}
</button>
</form>
{mutation.isError && <p style={{ color: 'red' }}>Error: {mutation.error?.message}</p>}
{mutation.isSuccess && <p style={{ color: 'green' }}>Profile saved!</p>}
</div>
);
}
export default App;
Common Pitfalls & Troubleshooting with TanStack Form
Even with powerful libraries, sometimes things don’t go as planned. Here are a few common issues and how to approach them:
Missing
nameProp or Mismatch:- Pitfall: Forgetting to add the
nameprop toform.Fieldor having it not exactly match a key in your Zod schema. - Symptom: Validation errors don’t show up for a field, or the field’s value doesn’t update in the form state. TypeScript might also complain about missing properties if
defaultValuesdoesn’t match the schema. - Troubleshooting: Double-check that
name="yourFieldName"in your<form.Field>precisely matchesyourFieldName: z.string()...in your Zod schema. Also ensuredefaultValuesincludes all fields defined in the schema.
- Pitfall: Forgetting to add the
Incorrect
onChangeHandling for Different Input Types:- Pitfall: Using
e.target.valuefor checkboxes (which neede.target.checked) or not converting string values from number inputs (e.target.value) to actual numbers for your schema. - Symptom: Boolean fields are always
trueorfalseregardless of interaction, or number fields fail validation because they receive a string. - Troubleshooting: Remember:
- Text, Email, Password, etc.:
(e) => field.handleChange(e.target.value) - Checkbox:
(e) => field.handleChange(e.target.checked) - Number:
(e) => field.handleChange(e.target.value === '' ? undefined : Number(e.target.value))(ornullinstead ofundefinedif your schema allowsnumber | null).
- Text, Email, Password, etc.:
- Pitfall: Using
Zod Schema Errors Not Propagating:
- Pitfall: Sometimes, complex Zod schemas or
.refine()methods don’t seem to produce errors correctly, or errors appear but aren’t displayed. - Symptom: The form submits even with invalid data, or
field.state.meta.errorsis empty when you expect errors. - Troubleshooting:
- Check your Zod schema directly: Test your
userProfileSchema.parse()method with invalid data in a separate file or console to ensure it throws the expected errors. - Inspect
form.state.meta.errorsandfield.state.meta.errors: Use your browser’s React DevTools to inspect theformobject (fromuseForm) and thefieldobject (fromform.Field). Look at theirstate.meta.errorsproperties to see what errors TanStack Form is actually receiving. - Ensure
validator: zodValidatoris correctly set: This connects Zod to the form. - For
.refine()errors: Remember thatpath: ['fieldName']directs the error message to a specific field. Ifpathis omitted, the error might appear inform.state.meta.errors(global form errors), notfield.state.meta.errors.
- Check your Zod schema directly: Test your
- Pitfall: Sometimes, complex Zod schemas or
By understanding these common areas, you’ll be well-equipped to debug your TanStack Forms efficiently.
Summary
Phew! You’ve successfully built a type-safe and accessible form using TanStack Form and Zod. Let’s recap the key takeaways from this chapter:
- TanStack Form is a headless library for managing form state, validation, and submission, giving you full control over UI and accessibility.
- Zod is your best friend for type-safe validation, allowing you to define schemas that infer TypeScript types and provide robust validation rules.
- The
useFormhook initializes your form, managing its overall state and orchestrating submission. - The
form.Fieldcomponent connects individual UI inputs to the form’s state, providing necessary props (value,onBlur,onChange) and error information (field.state.meta.errors). - Cross-field validation can be achieved effectively using Zod’s
.refine()method. - Seamless integration with TanStack Query allows you to handle form submissions as mutations, keeping your data fetching and caching consistent.
- Accessibility is a first-class citizen, as the headless nature empowers you to use semantic HTML and ARIA attributes.
You now have a powerful toolkit for building complex, reliable forms that are a pleasure for both developers and users. In the next chapter, we’ll shift gears and explore TanStack Store, a lightweight, immutable, and reactive data store that powers parts of the TanStack ecosystem and can be used for client-side state management.
References
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.