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-form for React, the core logic in @tanstack/form-core can 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.email is!
  • 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-live to 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.

graph TD A["User Interaction"] --> B{"Field Component/Hook"} B --> C["Form State"] C --> D{"Zod Schema Validation"} D -->|"Valid"| E["Update Form State & UI"] D -->|"Invalid (Errors)"| F["Update Form State & Display Errors"] E --> G["Form Submission"] G --> H["API Call"] H --> I["Update UI"]

Figure 9.1: TanStack Form Data Flow

  1. User Interaction: A user types into an input or blurs a field.
  2. Field Component/Hook: The Field component (or its hook equivalent) captures this event.
  3. Form State: The event updates the central form state.
  4. Zod Schema Validation: The entire form (or just the touched field, depending on configuration) is validated against the Zod schema.
  5. Valid Data: If valid, the form state is updated, and the UI reflects the new, valid data.
  6. Invalid Data: If invalid, the form state is updated with validation errors, which are then displayed in the UI.
  7. Form Submission: When the user attempts to submit, the form checks its overall validity.
  8. API Call: If valid, the onSubmit handler is triggered, typically making an API call (perhaps using TanStack Query, as we learned in Chapter 7).
  9. 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, which react-form builds 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 z from zod.
  • 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):

  1. import { useForm } from '@tanstack/react-form';: We import the main hook for creating forms in React.
  2. import { zodValidator } from '@tanstack/form-core';: We import the utility to integrate Zod with TanStack Form’s validation system.
  3. import { userProfileSchema, UserProfileForm } from './schemas/userProfileSchema';: We bring in our schema and its inferred type.
  4. const form = useForm<UserProfileForm>({...}):
    • This is the heart of our form. We call useForm, passing our UserProfileForm type to ensure type safety throughout.
    • defaultValues: An object setting the initial values for all our form fields. It’s crucial this matches the UserProfileForm type.
    • validator: zodValidator and validatorAdapter: () => ({ validator: userProfileSchema }): This tells TanStack Form to use Zod for validation and provides our specific userProfileSchema.
    • 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 validated value of the form.
  5. <form onSubmit={...}>:
    • We render a standard HTML <form> element.
    • e.preventDefault() and e.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 and onSubmit logic of TanStack Form.
  6. <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 your userProfileSchema. This is how TanStack Form links the UI element to the specific part of your form state and validation rules.
    • children={(field) => (...)}: The children prop uses a render prop pattern. It provides a field object 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 the firstName field. field.state.meta.errors is an array of strings.
  7. <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.Field blocks, one for lastName, one for email, and one for age.
  • Each name prop correctly maps to a key in our userProfileSchema.
  • Important for age (number input): HTML input type="number" elements return their value as a string. Our Zod schema expects a number | undefined. We handle this conversion in the onChange handler:
    • field.state.value ?? '': Displays an empty string in the input if field.state.value is undefined (which is our defaultValues for optional age).
    • val === '' ? undefined : Number(val): Converts an empty string back to undefined or a non-empty string to a number. This ensures our form state correctly holds a number | undefined for age.
  • 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.

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 a Promise that resolves after 1.5 seconds.
  • useQueryClient(): We get an instance of the QueryClient to 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. We invalidateQueries for ['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.
  • onSubmit handler in useForm: Instead of just logging, we now call mutation.mutate(value) to trigger our TanStack Query mutation.
  • Submit Button: The disabled prop now also checks mutation.isPending to ensure the button is disabled both during client-side form submission (validation) and during the actual API call. We also added UI feedback for mutation.isError and mutation.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:

  1. Add a new field called newsletterOptIn (boolean) to the userProfileSchema.
    • It should be optional, with a default of false.
  2. Add a new field called confirmEmail (string) to the userProfileSchema.
    • It should be required.
  3. Add a custom Zod validation rule to the entire schema that ensures confirmEmail exactly matches the email field. This is a common pattern for “confirm password” or “confirm email” fields.
  4. Render a checkbox for newsletterOptIn and a text input for confirmEmail in your App.tsx.
    • Remember how to handle boolean values for checkboxes.
    • Display validation errors for confirmEmail and a general form error if the emails don’t match.

Hint:

  • For checkbox onChange, you’ll likely use e.target.checked for the boolean value.
  • For cross-field validation with Zod, look into the .refine() method on the z.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:

  1. Missing name Prop or Mismatch:

    • Pitfall: Forgetting to add the name prop to form.Field or 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 defaultValues doesn’t match the schema.
    • Troubleshooting: Double-check that name="yourFieldName" in your <form.Field> precisely matches yourFieldName: z.string()... in your Zod schema. Also ensure defaultValues includes all fields defined in the schema.
  2. Incorrect onChange Handling for Different Input Types:

    • Pitfall: Using e.target.value for checkboxes (which need e.target.checked) or not converting string values from number inputs (e.target.value) to actual numbers for your schema.
    • Symptom: Boolean fields are always true or false regardless 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)) (or null instead of undefined if your schema allows number | null).
  3. 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.errors is 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.errors and field.state.meta.errors: Use your browser’s React DevTools to inspect the form object (from useForm) and the field object (from form.Field). Look at their state.meta.errors properties to see what errors TanStack Form is actually receiving.
      • Ensure validator: zodValidator is correctly set: This connects Zod to the form.
      • For .refine() errors: Remember that path: ['fieldName'] directs the error message to a specific field. If path is omitted, the error might appear in form.state.meta.errors (global form errors), not field.state.meta.errors.

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 useForm hook initializes your form, managing its overall state and orchestrating submission.
  • The form.Field component 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.