Imagine your space station has a crew registration panel. Each form field is like a sensor on the ship -- if the data is incorrect, the system must immediately notify the operator before the launch sequence is initiated. Error handling in forms is a key UX element of every React application.
In React, we can distinguish two main approaches to form validation:
Validation performed in the browser before data is sent to the server. It's fast and provides immediate feedback.
1function RegistrationForm() {
2 const [email, setEmail] = useState('');
3 const [errors, setErrors] = useState({});
4
5 const validateEmail = (value) => {
6 if (!value) return 'Email is required';
7 if (!value.includes('@')) return 'Invalid email format';
8 return null;
9 };
10
11 const handleChange = (e) => {
12 const value = e.target.value;
13 setEmail(value);
14 const error = validateEmail(value);
15 setErrors(prev => ({ ...prev, email: error }));
16 };
17
18 return (
19 <div>
20 <input value={email} onChange={handleChange} />
21 {errors.email && <p className="error">{errors.email}</p>}
22 </div>
23 );
24}The server validates the data after the form is submitted and returns errors. It's essential for security -- client-side validation can be bypassed.
1const handleSubmit = async (formData) => {
2 try {
3 const response = await fetch('/api/register', {
4 method: 'POST',
5 body: JSON.stringify(formData),
6 });
7 const result = await response.json();
8
9 if (!response.ok) {
10 // Server returned validation errors
11 setErrors(result.errors);
12 }
13 } catch (error) {
14 setErrors({ general: 'Connection error with server' });
15 }
16};Each form field should have its own error message, displayed directly below the field. It's like individual alarms for each system on a spacecraft.
1function CrewRegistration() {
2 const [formData, setFormData] = useState({
3 name: '', callsign: '', rank: ''
4 });
5 const [errors, setErrors] = useState({});
6 const [touched, setTouched] = useState({});
7
8 const validate = (field, value) => {
9 switch (field) {
10 case 'name':
11 if (!value.trim()) return 'Name is required';
12 if (value.length < 2) return 'Name must be at least 2 characters';
13 return null;
14 case 'callsign':
15 if (!value.trim()) return 'Callsign is required';
16 if (!/^[A-Z0-9-]+$/.test(value)) return 'Only uppercase letters, digits, and hyphens';
17 return null;
18 case 'rank':
19 if (!value) return 'Select a rank';
20 return null;
21 default:
22 return null;
23 }
24 };
25
26 const handleBlur = (field) => {
27 setTouched(prev => ({ ...prev, [field]: true }));
28 const error = validate(field, formData[field]);
29 setErrors(prev => ({ ...prev, [field]: error }));
30 };
31
32 const handleChange = (field, value) => {
33 setFormData(prev => ({ ...prev, [field]: value }));
34 // Validate only if the field has been touched
35 if (touched[field]) {
36 const error = validate(field, value);
37 setErrors(prev => ({ ...prev, [field]: error }));
38 }
39 };
40
41 return (
42 <form>
43 <div>
44 <label>Name</label>
45 <input
46 value={formData.name}
47 onChange={(e) => handleChange('name', e.target.value)}
48 onBlur={() => handleBlur('name')}
49 className={errors.name ? 'input-error' : ''}
50 />
51 {touched.name && errors.name && (
52 <p className="field-error">{errors.name}</p>
53 )}
54 </div>
55 {/* Same pattern for remaining fields */}
56 </form>
57 );
58}Notice the
touched pattern -- we only show errors after the user has left the field (onBlur). We don't want to scare the pilot with alarms before they've even finished entering data!The server can return errors that cannot be predicted on the client side -- e.g., "this email is already taken" or "registration limit exceeded".
1function SubmitWithServerErrors() {
2 const [errors, setErrors] = useState({});
3 const [serverError, setServerError] = useState(null);
4
5 const handleSubmit = async (e) => {
6 e.preventDefault();
7 setServerError(null);
8
9 try {
10 const response = await fetch('/api/crew/register', {
11 method: 'POST',
12 headers: { 'Content-Type': 'application/json' },
13 body: JSON.stringify(formData),
14 });
15
16 const data = await response.json();
17
18 if (!response.ok) {
19 if (data.fieldErrors) {
20 // Field-specific errors (e.g., "email already exists")
21 setErrors(data.fieldErrors);
22 }
23 if (data.message) {
24 // General server error
25 setServerError(data.message);
26 }
27 return;
28 }
29
30 // Success - redirect or show message
31 } catch (error) {
32 setServerError('Failed to connect to the base station');
33 }
34 };
35
36 return (
37 <form onSubmit={handleSubmit}>
38 {serverError && (
39 <div className="server-error-banner">{serverError}</div>
40 )}
41 {/* Form fields with field-level errors */}
42 </form>
43 );
44}Every form submission handler should be wrapped in
try-catch. Without it, a network error or unexpected server error will cause an unhandled exception.1const handleSubmit = async (e) => {
2 e.preventDefault();
3 setIsSubmitting(true);
4 setErrors({});
5
6 try {
7 // 1. Local validation
8 const validationErrors = validateAllFields(formData);
9 if (Object.keys(validationErrors).length > 0) {
10 setErrors(validationErrors);
11 return;
12 }
13
14 // 2. Send to server
15 const response = await submitToServer(formData);
16
17 // 3. Handle server response
18 if (!response.ok) {
19 const errorData = await response.json();
20 throw new Error(errorData.message || 'Server error');
21 }
22
23 // 4. Success
24 setSuccess(true);
25 } catch (error) {
26 // 5. Error handling
27 setErrors({ submit: error.message });
28 } finally {
29 // 6. Always executed - disable loader
30 setIsSubmitting(false);
31 }
32};Good error messages in forms should be:
aria-invalid and aria-describedby attributes1<div className="form-field">
2 <label htmlFor="password">Password</label>
3 <input
4 id="password"
5 type="password"
6 aria-invalid={!!errors.password}
7 aria-describedby={errors.password ? 'password-error' : undefined}
8 />
9 {errors.password && (
10 <p id="password-error" className="field-error" role="alert">
11 {errors.password}
12 </p>
13 )}
14</div>Remember -- a form is like a spacecraft's control panel. Every error must be communicated clearly and precisely so that the pilot (user) knows exactly what to fix. Never show mysterious error codes -- always translate them into human-readable language.