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.