React Form Validation: The Complete Guide
Everything you need to know about validating forms in React applications.
Why Form Validation Matters
Good form validation:
- Provides instant feedback to users
- Prevents invalid data from reaching your backend
- Improves user experience
- Reduces server load
- Protects against malicious input
Types of Validation
1. Client-Side Validation
Runs in the browser before submission. Provides instant feedback.
Pros:
- Instant user feedback
- Better UX (no page refresh)
- Reduces server requests
Cons:
- Can be bypassed
- Not a security measure
2. Server-Side Validation
Runs on the backend after submission. Security layer.
Pros:
- Cannot be bypassed
- Secure validation
- Access to database/business logic
Cons:
- Slower feedback
- Requires server request
The Right Approach: Both
Use client-side for UX, server-side for security. FormFlow does this automatically.
Client-Side Validation with FormFlow
Basic Required Fields
import { useFormFlow } from '@formflow.sh/react';
import { Input } from '@/components/ui/input';
function MyForm() {
const { register, handleSubmit, formState } = useFormFlow({
apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
});
return (
<form onSubmit={handleSubmit}>
<Input
{...register('email', { required: 'Email is required' })}
type="email"
/>
{formState.errors.email && (
<p className="text-red-500">{formState.errors.email.message}</p>
)}
<Input
{...register('name', { required: 'Name is required' })}
/>
{formState.errors.name && (
<p className="text-red-500">{formState.errors.name.message}</p>
)}
<button type="submit">Submit</button>
</form>
);
}Email Validation
<Input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
type="email"
/>Number Validation (Min/Max)
<Input
{...register('age', {
required: 'Age is required',
min: {
value: 18,
message: 'Must be at least 18 years old',
},
max: {
value: 120,
message: 'Invalid age',
},
})}
type="number"
/>String Length Validation
<Input
{...register('username', {
required: 'Username is required',
minLength: {
value: 3,
message: 'Username must be at least 3 characters',
},
maxLength: {
value: 20,
message: 'Username must be less than 20 characters',
},
})}
/>Custom Validation Functions
<Input
{...register('password', {
required: 'Password is required',
validate: {
hasUpperCase: (value) =>
/[A-Z]/.test(value) || 'Must contain uppercase letter',
hasLowerCase: (value) =>
/[a-z]/.test(value) || 'Must contain lowercase letter',
hasNumber: (value) =>
/[0-9]/.test(value) || 'Must contain number',
minLength: (value) =>
value.length >= 8 || 'Must be at least 8 characters',
},
})}
type="password"
/>Dependent Field Validation
const { register, watch } = useFormFlow({ ... });
const password = watch('password');
<Input
{...register('confirmPassword', {
required: 'Please confirm password',
validate: (value) =>
value === password || 'Passwords do not match',
})}
type="password"
/>Real-Time Validation
Show validation errors as user types:
const { register, formState, trigger } = useFormFlow({ ... });
<Input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email',
},
})}
onChange={(e) => {
// Trigger validation on change
trigger('email');
}}
/>
{formState.errors.email && (
<p className="text-red-500 text-sm mt-1">
{formState.errors.email.message}
</p>
)}Server-Side Validation
FormFlow automatically validates on the server. But you can add custom validation:
const { register, handleSubmit } = useFormFlow({
apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
onError: (error) => {
// Handle server validation errors
if (error.message.includes('email already exists')) {
// Show custom error to user
alert('This email is already registered');
}
},
});Validation Patterns
/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/iPhone (US)
/^(\+1)?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/URL
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b/Credit Card (Simple)
/^\d{13,19}$/Advanced: Async Validation
Check if username/email is available:
<Input
{...register('username', {
required: 'Username is required',
validate: async (value) => {
const response = await fetch(`/api/check-username/${value}`);
const data = await response.json();
return data.available || 'Username is taken';
},
})}
/>Error Display Patterns
Inline Errors
<div>
<Input {...register('email', { required: 'Required' })} />
{formState.errors.email && (
<p className="text-red-500 text-sm mt-1">
{formState.errors.email.message}
</p>
)}
</div>Error Summary at Top
{Object.keys(formState.errors).length > 0 && (
<div className="bg-red-50 border border-red-200 rounded p-4 mb-4">
<h3 className="font-semibold text-red-800 mb-2">
Please fix the following errors:
</h3>
<ul className="list-disc list-inside text-red-700 text-sm">
{Object.entries(formState.errors).map(([field, error]) => (
<li key={field}>{error.message}</li>
))}
</ul>
</div>
)}Toast Notifications
import { toast } from 'sonner';
const { register, handleSubmit } = useFormFlow({
apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
onSuccess: () => {
toast.success('Form submitted successfully!');
},
onError: (error) => {
toast.error('Failed to submit form: ' + error.message);
},
});Best Practices
1. Always Validate on Both Sides
Client-side for UX, server-side for security. Never trust client-only validation.
2. Provide Clear Error Messages
Bad: "Invalid input"
Good: "Email must be in format: user@example.com"
3. Validate on the Right Events
onBlur: Good for most fields (validate when user leaves field)onChange: Good for password strength, username availabilityonSubmit: Always validate before submission
4. Show Validation State Visually
<Input
{...register('email')}
className={
formState.errors.email
? 'border-red-500'
: formState.dirtyFields.email
? 'border-green-500'
: ''
}
/>5. Disable Submit Button During Validation
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{formState.isSubmitting ? 'Submitting...' : 'Submit'}
</Button>Complete Example: Registration Form
import { useFormFlow } from '@formflow.sh/react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
export function RegistrationForm() {
const { register, handleSubmit, formState, watch } = useFormFlow({
apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
onSuccess: () => {
alert('Registration successful!');
},
});
const password = watch('password');
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
{/* Email */}
<div>
<Label htmlFor="email">Email *</Label>
<Input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
id="email"
type="email"
/>
{formState.errors.email && (
<p className="text-red-500 text-sm mt-1">
{formState.errors.email.message}
</p>
)}
</div>
{/* Username */}
<div>
<Label htmlFor="username">Username *</Label>
<Input
{...register('username', {
required: 'Username is required',
minLength: {
value: 3,
message: 'Must be at least 3 characters',
},
pattern: {
value: /^[a-zA-Z0-9_]+$/,
message: 'Only letters, numbers, and underscores',
},
})}
id="username"
/>
{formState.errors.username && (
<p className="text-red-500 text-sm mt-1">
{formState.errors.username.message}
</p>
)}
</div>
{/* Password */}
<div>
<Label htmlFor="password">Password *</Label>
<Input
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Must be at least 8 characters',
},
validate: {
hasUpperCase: (v) =>
/[A-Z]/.test(v) || 'Must contain uppercase',
hasLowerCase: (v) =>
/[a-z]/.test(v) || 'Must contain lowercase',
hasNumber: (v) =>
/[0-9]/.test(v) || 'Must contain number',
},
})}
id="password"
type="password"
/>
{formState.errors.password && (
<p className="text-red-500 text-sm mt-1">
{formState.errors.password.message}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<Label htmlFor="confirmPassword">Confirm Password *</Label>
<Input
{...register('confirmPassword', {
required: 'Please confirm password',
validate: (value) =>
value === password || 'Passwords do not match',
})}
id="confirmPassword"
type="password"
/>
{formState.errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">
{formState.errors.confirmPassword.message}
</p>
)}
</div>
<Button
type="submit"
disabled={formState.isSubmitting}
className="w-full"
>
{formState.isSubmitting ? 'Registering...' : 'Register'}
</Button>
</form>
);
}Conclusion
Good form validation is essential for great UX and security. FormFlow makes it easy with:
- Built-in client-side validation (via react-hook-form)
- Automatic server-side validation
- Spam protection
- Rate limiting
You get production-grade validation out of the box.
Related Resources
- Compare FormFlow to Other Solutions - See how validation differs across libraries
- Build a Contact Form in 2 Minutes - Quick start guide with validation