/** * Async Validation Example * * Demonstrates: * - Async validation with API calls * - Debouncing to prevent excessive requests * - Loading states * - Error handling for async validation * - Request cancellation */ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useState, useRef, useEffect } from 'react' /** * Pattern 1: Async Validation in Zod Schema */ const usernameSchema = z.string() .min(3, 'Username must be at least 3 characters') .max(20, 'Username must not exceed 20 characters') .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores') .refine(async (username) => { // Check if username is available via API const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`) const { available } = await response.json() return available }, { message: 'Username is already taken', }) const signupSchemaWithAsync = z.object({ username: usernameSchema, email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), }) type SignupFormData = z.infer export function AsyncValidationForm() { const { register, handleSubmit, formState: { errors, isSubmitting, isValidating }, } = useForm({ resolver: zodResolver(signupSchemaWithAsync), mode: 'onBlur', // Validate on blur to avoid validating on every keystroke defaultValues: { username: '', email: '', password: '', }, }) const onSubmit = async (data: SignupFormData) => { console.log('Form data:', data) } return (

Sign Up

{isValidating && ( Checking availability... )} {errors.username && ( {errors.username.message} )}
{errors.email && ( {errors.email.message} )}
{errors.password && ( {errors.password.message} )}
) } /** * Pattern 2: Manual Async Validation with Debouncing and Cancellation * Better performance - more control over when validation happens */ const manualValidationSchema = z.object({ username: z.string() .min(3, 'Username must be at least 3 characters') .max(20, 'Username must not exceed 20 characters') .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'), email: z.string().email('Invalid email address'), }) type ManualValidationData = z.infer export function DebouncedAsyncValidationForm() { const { register, handleSubmit, watch, setError, clearErrors, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(manualValidationSchema), defaultValues: { username: '', email: '', }, }) const [isCheckingUsername, setIsCheckingUsername] = useState(false) const [isCheckingEmail, setIsCheckingEmail] = useState(false) const abortControllerRef = useRef(null) const timeoutRef = useRef(null) const username = watch('username') const email = watch('email') // Debounced username validation useEffect(() => { // Clear previous timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current) } // Skip if username is too short (already handled by Zod) if (!username || username.length < 3) { setIsCheckingUsername(false) return } // Debounce: wait 500ms after user stops typing timeoutRef.current = setTimeout(async () => { // Cancel previous request if (abortControllerRef.current) { abortControllerRef.current.abort() } // Create new abort controller abortControllerRef.current = new AbortController() setIsCheckingUsername(true) clearErrors('username') try { const response = await fetch( `/api/check-username?username=${encodeURIComponent(username)}`, { signal: abortControllerRef.current.signal } ) const { available } = await response.json() if (!available) { setError('username', { type: 'async', message: 'Username is already taken', }) } } catch (error: any) { if (error.name !== 'AbortError') { console.error('Username check error:', error) } } finally { setIsCheckingUsername(false) } }, 500) // 500ms debounce return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } } }, [username, setError, clearErrors]) // Debounced email validation useEffect(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } // Basic email validation first (handled by Zod) if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setIsCheckingEmail(false) return } timeoutRef.current = setTimeout(async () => { setIsCheckingEmail(true) clearErrors('email') try { const response = await fetch( `/api/check-email?email=${encodeURIComponent(email)}` ) const { available } = await response.json() if (!available) { setError('email', { type: 'async', message: 'Email is already registered', }) } } catch (error) { console.error('Email check error:', error) } finally { setIsCheckingEmail(false) } }, 500) return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } } }, [email, setError, clearErrors]) const onSubmit = async (data: ManualValidationData) => { // Final check before submission if (isCheckingUsername || isCheckingEmail) { return } console.log('Form data:', data) } return (

Create Account

{isCheckingUsername && (
)}
{errors.username && ( {errors.username.message} )} {!errors.username && username.length >= 3 && !isCheckingUsername && ( Username is available ✓ )}
{isCheckingEmail && (
)}
{errors.email && ( {errors.email.message} )} {!errors.email && email && !isCheckingEmail && ( Email is available ✓ )}
) } /** * Mock API endpoints for testing */ export async function checkUsernameAvailability(username: string): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 1000)) // Mock: usernames starting with 'test' are taken return !username.toLowerCase().startsWith('test') } export async function checkEmailAvailability(email: string): Promise { await new Promise(resolve => setTimeout(resolve, 1000)) // Mock: emails with 'test' are taken return !email.toLowerCase().includes('test') }