Building the Login and Register Forms

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;
Note: The 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.
Tip: Always disable form inputs and the submit button during API calls (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.
Warning: Store the JWT token securely. In this lesson it goes to 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(...) }

🧠 Test Yourself

After a successful login your Axios call returns res.data.token. You call navigate(from, { replace: true }). Why replace: true instead of a regular navigate?