Lesson 17

Building Step 1: Personal Info

Step one of the registration wizard collects the basics: name, email, and a password. Let's build StepPersonalInfo and wire up validation for all five fields.

The Zod Schema

Define the shape of this step's data upfront so validation rules are clear:

import { z } from 'zod'

const personalInfoSchema = z.object({
  firstName: z.string().min(1, 'Required').min(2, 'Too short'),
  lastName: z.string().min(1, 'Required').min(2, 'Too short'),
  email: z.string().min(1, 'Required').email('Invalid email address'),
  password: z.string().min(8, 'Must be at least 8 characters'),
  confirmPassword: z.string().min(1, 'Required'),
})

Two min calls on firstName and lastName handle two different cases: an empty field gets "Required", and a single character gets "Too short". Order matters — Zod runs them in sequence and stops at the first failure.

The Component Structure

StepPersonalInfo receives the form instance as a prop. TanStack Form passes field state and handlers down through the render prop on form.Field.

The first four fields — firstName, lastName, email, and password — follow the same pattern: a label, an input, and a conditional error message that only shows after the field has been touched.

The fifth field, confirmPassword, needs cross-field validation. You can't do that with a plain Zod schema because the schema for that field doesn't know about the password field. Instead, use a custom function validator that reads the current password value directly from the form:

validators={{
  onBlur: ({ value }) => {
    const password = form.getFieldValue('password')
    if (value !== password) return 'Passwords do not match'
    return undefined
  },
}}

Returning undefined means valid. Returning a string means invalid. Simple.

The Full Component

function StepPersonalInfo({ form }: { form: ReturnType<typeof useForm> }) {
  return (
    <div>
      <h2>Personal Information</h2>

      <form.Field
        name="firstName"
        validators={{ onBlur: z.string().min(1, 'Required').min(2, 'Too short') }}
        validatorAdapter={zodValidator()}
        children={(field) => (
          <div>
            <label htmlFor="firstName">First Name</label>
            <input
              id="firstName"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
            {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />

      <form.Field
        name="lastName"
        validators={{ onBlur: z.string().min(1, 'Required').min(2, 'Too short') }}
        validatorAdapter={zodValidator()}
        children={(field) => (
          <div>
            <label htmlFor="lastName">Last Name</label>
            <input
              id="lastName"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
            {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />

      <form.Field
        name="email"
        validators={{ onBlur: z.string().min(1, 'Required').email('Invalid email address') }}
        validatorAdapter={zodValidator()}
        children={(field) => (
          <div>
            <label htmlFor="email">Email</label>
            <input
              id="email"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
            {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />

      <form.Field
        name="password"
        validators={{ onBlur: z.string().min(8, 'Must be at least 8 characters') }}
        validatorAdapter={zodValidator()}
        children={(field) => (
          <div>
            <label htmlFor="password">Password</label>
            <input
              id="password"
              type="password"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
            {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />

      <form.Field
        name="confirmPassword"
        validators={{
          onBlur: ({ value }) => {
            const password = form.getFieldValue('password')
            if (value !== password) return 'Passwords do not match'
            return undefined
          },
        }}
        children={(field) => (
          <div>
            <label htmlFor="confirmPassword">Confirm Password</label>
            <input
              id="confirmPassword"
              type="password"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
            {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
              <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
            )}
          </div>
        )}
      />
    </div>
  )
}

Notice confirmPassword doesn't use validatorAdapter — it doesn't need one since the validator is a plain function, not a Zod schema.

Each field validates onBlur, which gives users a chance to fill in the field before getting an error. The isTouched check keeps the UI clean on first render — errors only appear after the user has interacted with the field.

Step 1 is complete. You have a validated personal info form that blocks forward navigation until every field passes, and a StepProps contract that any step component can implement.