React forms and validation: Step-by-step guide
What is React Forms and Validation?
React forms and validation involve managing user input, handling form state, and ensuring data integrity before submission. React provides two main approaches: controlled components (where React manages the form state) and uncontrolled components (where the DOM manages the state). Proper validation ensures users submit correct data, provides helpful feedback, and improves user experience.
Why Should You Care?
Forms are the primary way users interact with web applications. Poor form implementation leads to frustrated users, data quality issues, and security vulnerabilities. Good forms provide immediate feedback, handle edge cases gracefully, and guide users toward successful completion. Mastering React forms is essential for building user-friendly applications.
Before You Start
Prerequisites
- Understanding of React hooks (useState, useEffect)
- Basic knowledge of HTML forms
- Familiarity with JavaScript validation concepts
What You’ll Need
- React project set up
- Form validation library (optional but recommended)
- Styling solution (CSS modules, Tailwind, etc.)
Step-by-Step Tutorial
Step 1: Basic Controlled Component
Simple Controlled Form:
import { useState } from 'react';
interface ContactFormData {
name: string;
email: string;
message: string;
}
export function ContactForm() {
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
message: '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted:', formData);
// Handle form submission
};
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md mx-auto">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows={4}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Submit
</button>
</form>
);
}
Step 2: Adding Form Validation
Custom Validation Logic:
import { useState, useEffect } from 'react';
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
export function ValidatedContactForm() {
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
message: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validateField = (name: string, value: string): string | undefined => {
switch (name) {
case 'name':
if (!value.trim()) return 'Name is required';
if (value.length < 2) return 'Name must be at least 2 characters';
return undefined;
case 'email':
if (!value.trim()) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email address';
}
return undefined;
case 'message':
if (!value.trim()) return 'Message is required';
if (value.length < 10) return 'Message must be at least 10 characters';
return undefined;
default:
return undefined;
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Validate field if it has been touched
if (touched[name]) {
const error = validateField(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
const error = validateField(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
let isValid = true;
Object.keys(formData).forEach(key => {
const error = validateField(key, formData[key as keyof ContactFormData]);
if (error) {
newErrors[key as keyof FormErrors] = error;
isValid = false;
}
});
setErrors(newErrors);
setTouched(Object.keys(formData).reduce((acc, key) => ({ ...acc, [key]: true }), {}));
return isValid;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Form submitted:', formData);
// Handle successful submission
} catch (error) {
console.error('Submission error:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md mx-auto">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.name ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.name && touched.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.email && touched.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}
rows={4}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.message ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.message && touched.message && (
<p className="text-red-500 text-sm mt-1">{errors.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Step 3: Using React Hook Form with Zod
Advanced Form with React Hook Form:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';
// Define validation schema with Zod
const contactSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name must be less than 50 characters'),
email: z
.string()
.email('Please enter a valid email address'),
phone: z
.string()
.regex(/^\+?[\d\s-()]+$/, 'Please enter a valid phone number')
.optional(),
subject: z
.string()
.min(5, 'Subject must be at least 5 characters'),
message: z
.string()
.min(10, 'Message must be at least 10 characters')
.max(500, 'Message must be less than 500 characters'),
newsletter: z.boolean().default(false),
});
type ContactFormData = z.infer<typeof contactSchema>;
export function AdvancedContactForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty, isValid },
reset,
watch,
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
mode: 'onChange',
defaultValues: {
name: '',
email: '',
phone: '',
subject: '',
message: '',
newsletter: false,
},
});
const watchedValues = watch();
const onSubmit = async (data: ContactFormData) => {
setIsSubmitting(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
console.log('Form submitted:', data);
setSubmitSuccess(true);
reset();
// Hide success message after 5 seconds
setTimeout(() => setSubmitSuccess(false), 5000);
} catch (error) {
console.error('Submission error:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto">
{submitSuccess && (
<div className="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
Thank you for your message! We'll get back to you soon.
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name *
</label>
<input
{...register('name')}
type="text"
id="name"
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.name ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="John Doe"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
{...register('email')}
type="email"
id="email"
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="[email protected]"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</div>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-1">
Phone (Optional)
</label>
<input
{...register('phone')}
type="tel"
id="phone"
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.phone ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="+1 (555) 123-4567"
/>
{errors.phone && (
<p className="text-red-500 text-sm mt-1">{errors.phone.message}</p>
)}
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium mb-1">
Subject *
</label>
<input
{...register('subject')}
type="text"
id="subject"
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.subject ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="How can we help you?"
/>
{errors.subject && (
<p className="text-red-500 text-sm mt-1">{errors.subject.message}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message *
</label>
<textarea
{...register('message')}
id="message"
rows={5}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.message ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Tell us more about your project..."
/>
<div className="flex justify-between items-center mt-1">
{errors.message && (
<p className="text-red-500 text-sm">{errors.message.message}</p>
)}
<span className="text-sm text-gray-500">
{watchedValues.message?.length || 0}/500
</span>
</div>
</div>
<div className="flex items-center">
<input
{...register('newsletter')}
type="checkbox"
id="newsletter"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="newsletter" className="ml-2 text-sm text-gray-700">
Subscribe to our newsletter for updates and tips
</label>
</div>
<button
type="submit"
disabled={!isDirty || !isValid || isSubmitting}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isSubmitting ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending...
</span>
) : (
'Send Message'
)}
</button>
</form>
</div>
);
}
Step 4: Building Reusable Form Components
Custom Form Components:
// components/FormInput.tsx
import { UseFormRegister, FieldErrors } from 'react-hook-form';
interface FormInputProps {
label: string;
name: string;
type?: string;
placeholder?: string;
register: UseFormRegister<any>;
error?: FieldErrors;
required?: boolean;
className?: string;
}
export function FormInput({
label,
name,
type = 'text',
placeholder,
register,
error,
required = false,
className = '',
}: FormInputProps) {
const errorMessage = error?.[name]?.message as string | undefined;
return (
<div className={className}>
<label htmlFor={name} className="block text-sm font-medium mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
<input
{...register(name)}
type={type}
id={name}
placeholder={placeholder}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errorMessage ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
)}
</div>
);
}
// components/FormTextArea.tsx
interface FormTextAreaProps {
label: string;
name: string;
placeholder?: string;
register: UseFormRegister<any>;
error?: FieldErrors;
required?: boolean;
rows?: number;
maxLength?: number;
value?: string;
className?: string;
}
export function FormTextArea({
label,
name,
placeholder,
register,
error,
required = false,
rows = 4,
maxLength,
value,
className = '',
}: FormTextAreaProps) {
const errorMessage = error?.[name]?.message as string | undefined;
return (
<div className={className}>
<label htmlFor={name} className="block text-sm font-medium mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
<textarea
{...register(name)}
id={name}
placeholder={placeholder}
rows={rows}
maxLength={maxLength}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errorMessage ? 'border-red-500' : 'border-gray-300'
}`}
/>
<div className="flex justify-between items-center mt-1">
{errorMessage && (
<p className="text-red-500 text-sm">{errorMessage}</p>
)}
{maxLength && (
<span className="text-sm text-gray-500">
{value?.length || 0}/{maxLength}
</span>
)}
</div>
</div>
);
}
What’s Next?
Advanced Form Patterns:
- Multi-step forms with wizard navigation
- Conditional field rendering
- File upload handling
- Real-time validation with debouncing
- Form persistence and auto-save
Integration Patterns:
- API integration with error handling
- Progress indicators and loading states
- Success/error messaging
- Form analytics and tracking
Accessibility Considerations:
- ARIA labels and descriptions
- Keyboard navigation
- Screen reader support
- Focus management
Common Questions
Q: Should I use controlled or uncontrolled components? Use controlled components for most cases as they provide better control and validation. Use uncontrolled components for simple forms or when integrating with non-React libraries.
Q: When should I use React Hook Form? Use React Hook Form for complex forms with extensive validation, performance-critical forms, or when you need advanced features like field arrays and conditional validation.
Q: How do I handle file uploads? Use FormData API for file uploads, handle progress with XMLHttpRequest or fetch with ReadableStream, and validate file types and sizes on the client and server.
Tools & Resources
- React Hook Form - Performant forms with easy validation
- Zod - TypeScript-first schema validation
- Formik - Popular form library with extensive ecosystem
- React Final Form - Subscription-based form state management
Related Topics
Form Fundamentals & State Management
- How to Implement React Hooks State Management
- Common React Pitfalls and Solutions
- Solving React Component Challenges: Practical Approach
Type Safety & Validation
- TypeScript with React: Component Patterns and Type Safety
- TypeScript Type Guards and Narrowing: Runtime Type Safety
- Quick Start Guide to React TypeScript
Error Handling & Testing
- JavaScript Error Handling: Try/Catch Patterns and Modern Error Management
- React Testing Mistakes to Avoid and How to Fix Them
Performance & Debugging
- React Performance Optimization: Complete Guide
- Advanced React Debugging Techniques for Professionals
Need Help With Implementation?
Building robust forms with proper validation requires understanding user experience, accessibility, and performance considerations. Built By Dakic specializes in creating user-friendly forms that convert, validate properly, and integrate seamlessly with backend systems. Get in touch for a free consultation and discover how we can help you build better forms.