JavaScript and React course Β· Module 16: Error Handling and Suspense

Error Handling in Forms

5 min read
In this lesson5

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.

Form Validation Patterns

In React, we can distinguish two main approaches to form validation:

1. Client-side 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}

2. Server-side Validation

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};

Displaying Field-Level Errors

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!

Handling Server Errors in Forms

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}

try-catch in handleSubmit

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};

UX Best Practices for Error Messages

Good error messages in forms should be:

  1. Specific -- "Password must be at least 8 characters" instead of "Invalid password"
  2. Helpful -- tell the user HOW to fix the error
  3. Visible -- display the error near the field it relates to
  4. Gentle -- don't show all errors at once before the first submit
  5. Accessible -- use aria-invalid and aria-describedby attributes
1<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.

Code for this lesson: App.jsx
1import React, { useState } from 'react';
2import './styles.css';
3
4// Crew member registration form with validation
5function CrewRegistrationForm() {
6  const [formData, setFormData] = useState({
7    name: '', callsign: '', email: '', rank: ''
8  });
9  const [errors, setErrors] = useState({});
10  const [touched, setTouched] = useState({});
11  const [serverError, setServerError] = useState(null);
12  const [isSubmitting, setIsSubmitting] = useState(false);
13  const [success, setSuccess] = useState(false);
14
15  const validate = (field, value) => {
16    switch (field) {
17      case 'name':
18        if (!value.trim()) return 'Name is required';
19        if (value.length < 2) return 'Name must have at least 2 characters';
20        return null;
21      case 'callsign':
22        if (!value.trim()) return 'Callsign is required';
23        if (!/^[A-Za-z0-9-]+$/.test(value))
24          return 'Only letters, digits, and hyphens';
25        return null;
26      case 'email':
27        if (!value.trim()) return 'Email is required';
28        if (!value.includes('@')) return 'Invalid email format';
29        return null;
30      case 'rank':
31        if (!value) return 'Choose a rank';
32        return null;
33      default:
34        return null;
35    }
36  };
37
38  const handleChange = (field, value) => {
39    setFormData(prev => ({ ...prev, [field]: value }));
40    if (touched[field]) {
41      const error = validate(field, value);
42      setErrors(prev => ({ ...prev, [field]: error }));
43    }
44  };
45
46  const handleBlur = (field) => {
47    setTouched(prev => ({ ...prev, [field]: true }));
48    const error = validate(field, formData[field]);
49    setErrors(prev => ({ ...prev, [field]: error }));
50  };
51
52  const handleSubmit = async (e) => {
53    e.preventDefault();
54    setServerError(null);
55    setIsSubmitting(true);
56
57    // Validate all fields
58    const allErrors = {};
59    const allTouched = {};
60    for (const field of Object.keys(formData)) {
61      allTouched[field] = true;
62      const error = validate(field, formData[field]);
63      if (error) allErrors[field] = error;
64    }
65    setTouched(allTouched);
66    setErrors(allErrors);
67
68    if (Object.keys(allErrors).length > 0) {
69      setIsSubmitting(false);
70      return;
71    }
72
73    try {
74      // Simulating sending to the server
75      await new Promise(resolve => setTimeout(resolve, 1000));
76
77      // Simulating a server error (50% chance)
78      if (Math.random() > 0.5) {
79        throw new Error('Server rejected registration: callsign already exists');
80      }
81
82      setSuccess(true);
83    } catch (error) {
84      setServerError(error.message);
85    } finally {
86      setIsSubmitting(false);
87    }
88  };
89
90  if (success) {
91    return (
92      <div className="success-panel">
93        <h2>Registration successful!</h2>
94        <p>Welcome aboard, {formData.callsign}!</p>
95        <button onClick={() => { setSuccess(false); setFormData({ name: '', callsign: '', email: '', rank: '' }); setTouched({}); }}>
96          Register another
97        </button>
98      </div>
99    );
100  }
101
102  return (
103    <form onSubmit={handleSubmit} className="form">
104      {serverError && (
105        <div className="server-error">{serverError}</div>
106      )}
107
108      <div className="field">
109        <label>Name</label>
110        <input
111          value={formData.name}
112          onChange={(e) => handleChange('name', e.target.value)}
113          onBlur={() => handleBlur('name')}
114          className={touched.name && errors.name ? 'input-error' : ''}
115          placeholder="e.g. John Smith"
116        />
117        {touched.name && errors.name && (
118          <p className="field-error">{errors.name}</p>
119        )}
120      </div>
121
122      <div className="field">
123        <label>Callsign</label>
124        <input
125          value={formData.callsign}
126          onChange={(e) => handleChange('callsign', e.target.value)}
127          onBlur={() => handleBlur('callsign')}
128          className={touched.callsign && errors.callsign ? 'input-error' : ''}
129          placeholder="Np. NOVA-7"
130        />
131        {touched.callsign && errors.callsign && (
132          <p className="field-error">{errors.callsign}</p>
133        )}
134      </div>
135
136      <div className="field">
137        <label>Email</label>
138        <input
139          value={formData.email}
140          onChange={(e) => handleChange('email', e.target.value)}
141          onBlur={() => handleBlur('email')}
142          className={touched.email && errors.email ? 'input-error' : ''}
143          placeholder="e.g. john@station.space"
144        />
145        {touched.email && errors.email && (
146          <p className="field-error">{errors.email}</p>
147        )}
148      </div>
149
150      <div className="field">
151        <label>Rank</label>
152        <select
153          value={formData.rank}
154          onChange={(e) => handleChange('rank', e.target.value)}
155          onBlur={() => handleBlur('rank')}
156          className={touched.rank && errors.rank ? 'input-error' : ''}
157        >
158          <option value="">-- Choose a rank --</option>
159          <option value="cadet">Cadet</option>
160          <option value="officer">Officer</option>
161          <option value="commander">Commander</option>
162        </select>
163        {touched.rank && errors.rank && (
164          <p className="field-error">{errors.rank}</p>
165        )}
166      </div>
167
168      <button type="submit" className="submit-btn" disabled={isSubmitting}>
169        {isSubmitting ? 'Sending...' : 'Register crew member'}
170      </button>
171    </form>
172  );
173}
174
175export default function App() {
176  return (
177    <div className="app">
178      <h1>Crew Registration</h1>
179      <p className="subtitle">Form validation with error handling</p>
180      <CrewRegistrationForm />
181    </div>
182  );
183}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the main advantage of client-side form validation?

  2. 2. Why do we use the touched pattern (onBlur) when displaying validation errors?

Hands-on tasks in the game

  • Click in order

    Click elements in order for the handleSubmit form steps:

  • Code editor

    Implement a form with validation and error handling

  • Horizontal ordering

    Arrange the correct syntax for conditionally displaying a field error:

  • Vertical ordering

    Order the types of form errors from earliest handled to latest:

Useful articles