import { z } from 'zod';
export const signupSchema = z
.object({
email: z.string().min(1, 'Email is required').email('Enter a valid email'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[0-9]/, 'Include at least one number'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
export type SignupValues = z.infer<typeof signupSchema>;
import { useForm, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, type SignupValues } from './signup.schema';
export function useSignupForm(onValid: (values: SignupValues) => Promise<void>) {
const form = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
mode: 'onTouched',
defaultValues: {
email: '',
password: '',
confirmPassword: '',
acceptTerms: false as unknown as true,
},
});
const submit: SubmitHandler<SignupValues> = async (values) => {
try {
await onValid(values);
} catch (err) {
form.setError('root.serverError', {
message: err instanceof Error ? err.message : 'Something went wrong',
});
}
};
return {
...form,
onSubmit: form.handleSubmit(submit),
};
}
import { useSignupForm } from './useSignupForm';
import type { SignupValues } from './signup.schema';
async function createAccount(values: SignupValues) {
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Email already registered');
}
export function SignupForm() {
const {
register,
onSubmit,
formState: { errors, isSubmitting },
} = useSignupForm(createAccount);
return (
<form onSubmit={onSubmit} noValidate>
<label>
Email
<input type="email" {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</label>
<label>
Password
<input type="password" {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}
</label>
<label>
Confirm password
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && (
<span role="alert">{errors.confirmPassword.message}</span>
)}
</label>
<label>
<input type="checkbox" {...register('acceptTerms')} />
I accept the terms
</label>
{errors.acceptTerms && <span role="alert">{errors.acceptTerms.message}</span>}
{errors.root?.serverError && (
<p role="alert">{errors.root.serverError.message}</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating…' : 'Create account'}
</button>
</form>
);
}
This snippet shows how a form's runtime validation and its compile-time types can be derived from a single source of truth using Zod, then wired into React Hook Form through the @hookform/resolvers/zod resolver. The pattern removes the classic drift between hand-written TypeScript interfaces and hand-written validation logic: the schema declares both.
In signup.schema.ts, signupSchema describes the shape and constraints of the form — email format, password length, and a cross-field refine that ensures password and confirmPassword match. The refine attaches its error to a specific path, so the message surfaces on the confirmPassword field rather than at the form root. z.infer<typeof signupSchema> then produces the SignupValues type, which every consumer imports; changing the schema automatically updates the types downstream.
The useSignupForm hook centralizes the RHF setup. Passing zodResolver(signupSchema) to useForm means every submit and every configured validation trigger runs the input through Zod, and validation errors are mapped back onto formState.errors keyed by field name. mode: 'onTouched' is chosen so fields validate after first blur rather than on every keystroke, which avoids flashing errors while the user is still typing. The hook wraps handleSubmit with the caller-supplied onValid, keeping the async submission concern out of the component.
SignupForm.tsx consumes the hook and stays purely presentational. Because register is typed against SignupValues, a typo in a field name is a compile error rather than a silent no-op. Each input reads its error from errors, and the submit button disables while isSubmitting is true to prevent double submits.
The key trade-off is that all validation lives in the schema, so complex conditional rules require Zod features like superRefine or discriminated unions rather than ad-hoc component code. In exchange, the form gets one authoritative definition, end-to-end type safety, and validation that is trivially reusable on a server that shares the same schema.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.