The login and register forms are the gateway to every authenticated feature in the MERN Blog. Get them right โ clean state management, proper validation, clear error messages, and smooth API integration โ and every subsequent form in the application is easier to build. In this lesson you will build both forms from scratch: controlled inputs with a generic onChange handler, client-side validation, Axios calls to the Express auth API, server-side error display, and post-login redirection with useNavigate.
The Login Form
// src/pages/LoginPage.jsx
import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import axios from 'axios';
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from || '/dashboard';
const [formData, setFormData] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState('');
// Generic handler โ works for all text inputs
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear field-level error as user corrects it
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }));
setApiError(''); // clear API error on any change
};
// Client-side validation โ runs before API call
const validate = () => {
const errs = {};
if (!formData.email) errs.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(formData.email)) errs.email = 'Enter a valid email';
if (!formData.password) errs.password = 'Password is required';
return errs;
};
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length) {
setErrors(validationErrors);
return;
}
setLoading(true);
setApiError('');
try {
const res = await axios.post('/api/auth/login', formData);
// Store JWT token โ Chapter 21 moves this into AuthContext
localStorage.setItem('token', res.data.token);
navigate(from, { replace: true }); // redirect to original destination
} catch (err) {
// Express returns structured error โ show it to the user
setApiError(err.response?.data?.message || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<h1>Log In</h1>
{location.state?.from && (
<p className="auth-page__notice">
Please log in to access {location.state.from}
</p>
)}
{apiError && <div className="alert alert--error">{apiError}</div>}
<form onSubmit={handleSubmit} noValidate>
<div className="form-group">
<label htmlFor="email">Email address</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className={errors.email ? 'input input--error' : 'input'}
autoComplete="email"
disabled={loading}
/>
{errors.email && <p className="field-error">{errors.email}</p>}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
className={errors.password ? 'input input--error' : 'input'}
autoComplete="current-password"
disabled={loading}
/>
{errors.password && <p className="field-error">{errors.password}</p>}
</div>
<button type="submit" className="btn btn--primary btn--full" disabled={loading}>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
<p className="auth-page__switch">
Do not have an account? <Link to="/register">Register</Link>
</p>
</div>
);
}
export default LoginPage;
noValidate attribute on the form element disables the browser’s built-in HTML5 validation UI (red borders, popup tooltips). This is deliberate โ you want React to own all validation so you can control the error message placement, styling, and timing. Without noValidate, the browser may show its own validation popups that conflict with your React error messages.disabled={loading}). This prevents the user from double-submitting the form while the request is in flight. It also provides clear visual feedback that something is happening. Pair the disabled state with a button label change (“Logging in…” instead of “Log In”) for extra clarity.localStorage for simplicity โ but localStorage is accessible via JavaScript and vulnerable to XSS attacks. In Chapter 22 (JWT Authentication) you will learn more about token storage options and their trade-offs. For a production MERN application, consider storing the refresh token in an HttpOnly cookie instead of localStorage.The Register Form
// src/pages/RegisterPage.jsx
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
function RegisterPage() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
name: '', email: '', password: '', confirmPassword: '',
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }));
setApiError('');
};
const validate = () => {
const errs = {};
if (!formData.name || formData.name.trim().length < 2)
errs.name = 'Name must be at least 2 characters';
if (!formData.email || !/\S+@\S+\.\S+/.test(formData.email))
errs.email = 'Enter a valid email address';
if (!formData.password || formData.password.length < 8)
errs.password = 'Password must be at least 8 characters';
if (formData.password !== formData.confirmPassword)
errs.confirmPassword = 'Passwords do not match';
return errs;
};
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length) { setErrors(validationErrors); return; }
setLoading(true);
try {
const { confirmPassword, ...payload } = formData; // exclude confirmPassword
const res = await axios.post('/api/auth/register', payload);
localStorage.setItem('token', res.data.token);
navigate('/dashboard', { replace: true });
} catch (err) {
// Handle duplicate email (409) with a descriptive message
if (err.response?.status === 409) {
setErrors(prev => ({ ...prev, email: 'This email is already registered' }));
} else {
setApiError(err.response?.data?.message || 'Registration failed. Please try again.');
}
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<h1>Create Account</h1>
{apiError && <div className="alert alert--error">{apiError}</div>}
<form onSubmit={handleSubmit} noValidate>
{[
{ name: 'name', label: 'Name', type: 'text', auto: 'name' },
{ name: 'email', label: 'Email', type: 'email', auto: 'email' },
{ name: 'password', label: 'Password', type: 'password', auto: 'new-password' },
{ name: 'confirmPassword', label: 'Confirm Password', type: 'password', auto: 'new-password' },
].map(({ name, label, type, auto }) => (
<div key={name} className="form-group">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={formData[name]}
onChange={handleChange}
className={errors[name] ? 'input input--error' : 'input'}
autoComplete={auto}
disabled={loading}
/>
{errors[name] && <p className="field-error">{errors[name]}</p>}
</div>
))}
<button type="submit" className="btn btn--primary btn--full" disabled={loading}>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="auth-page__switch">
Already have an account? <Link to="/login">Log in</Link>
</p>
</div>
);
}
export default RegisterPage;
Common Mistakes
Mistake 1 โ Sending confirmPassword to the API
โ Wrong โ the server does not expect a confirmPassword field:
await axios.post('/api/auth/register', formData); // includes confirmPassword!
โ Correct โ destructure it out before sending:
const { confirmPassword, ...payload } = formData;
await axios.post('/api/auth/register', payload); // โ clean payload
Mistake 2 โ Not clearing API errors when the user corrects input
โ Wrong โ old API error stays visible even when user fixes the issue:
const handleChange = (e) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
// API error stays visible โ confusing UX
};
โ Correct โ clear the API error on any change:
const handleChange = (e) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
setApiError(''); // โ fresh start on each correction
};
Mistake 3 โ Not handling the 409 duplicate email case specifically
โ Wrong โ showing a generic error for a duplicate email:
setApiError('Registration failed'); // user does not know why
โ Correct โ detect 409 and show a targeted field error:
if (err.response?.status === 409) {
setErrors(prev => ({ ...prev, email: 'This email is already registered' }));
} // โ error appears next to the email field
Quick Reference
| Task | Code |
|---|---|
| Generic handler | setFormData(prev => ({ ...prev, [e.target.name]: e.target.value })) |
| Prevent double submit | disabled={loading} on inputs and button |
| Disable browser validation | noValidate on <form> |
| Show field error | {errors.email && <p className="field-error">{errors.email}</p>} |
| Show API error | {apiError && <div className="alert--error">{apiError}</div>} |
| Redirect after login | navigate(from, { replace: true }) |
| Strip extra field | const { confirmPassword, ...payload } = formData |
| Handle 409 | if (err.response?.status === 409) { setErrors(...) } |