Skip to main content
โšก Calmops

Web Form Validation: HTML5 Constraint API, JavaScript, and React

Introduction

ommon cases (required fields, email format, number ranges) without JavaScript. For complex cases, you add JavaScript on top.

Validation should happen on both client and server. Client-side validation improves UX; server-side validation is the security boundary.

HTML5 Constraint Validation

The browser validates automatically when you use the right input types and attributes:

<form>
  <!-- Required field -->
  <input type="text" name="name" required>

  <!-- Email format -->
  <inpu" name="email" required>

  <!-- URL format -->
  <input type="url" name="website">

  <!-- Number with range -->
  <input type="number" name="age" min="18" max="120" required>

  <!-- Password with minimum length -->
  <input type="password" name="password" minlength="8" required>

  <!-- Pattern matching -->
  <input type="text" name="zipcode" pattern="[0-9]{5}" title="5-digit ZIP code">

  <!-- Phone number -->
  <input type="tel" name="phone" pattern="[0-9]{10,11}">

  <button type="submit">Submit</button>
</form>

The browser shows error messages automatically when the form is submitted. No JavaScript needed for basic validation.

The Constraint Validation API

Use JavaScript to check validity programmatically and customize error messages:

const form = document.getElementById('myForm');
const emailInput = document.getElementById('email');

// Check if a field is valid
emailInput.checkValidity()  // returns true/false

// Get the validation state
emailInput.validity.valueMissing   // true if required and empty
emailInput.validity.typeMismatch   // true if wrong type (e.g., not an email)
emailInput.validity.patternMismatch // true if pattern doesn't match
emailInput.validShort       // true if below minlength
emailInput.validity.tooLong        // true if above maxlength
emailInput.validity.rangeUnderflow // true if below min
emailInput.validity.rangeOverflow  // true if above max
emailInput.validity.valid          // true if all constraints pass

// Set a custom error message
emailInput.setCustomValidity('Please enter a company email address');
emailInput.setCustomValidity('');  // clear the error

Custom Validation with Inline Error Messages

Show errors next to each field instead of browser popups:

<form id="contactForm" novalidate>
  <div class="field">
    <label for="name">Name *</label>
    <input id="name" type="text" required minlength="2">
    <span class="error" aria-live="polite"></span>
  </div>

  <div class="field">
    <label for="email">Email *</label>
    <input id="email" type="email" required>
    <span class="error" aria-live="polite"></span>
  </div>

  <div class="field">
    <label for="phone">Phone</label>
    <tern="[0-9]{10,11}">
    <span class="error" aria-live="polite"></span>
  </div>

  <button type="submit">Submit</button>
</form>
// form-validation.js
const form = document.getElementById('contactForm');

// Custom error messages per validation type
const errorMessages = {
    valueMissing:    'This field is required.',
    typeMismatch:    {
        email: 'Please enter a valid email address.',
        url:   'Please enter a valid URL.',
    },
    patternMismatch:d format.',
    tooShort:        (input) => `Minimum ${input.minLength} characters required.`,
    tooLong:         (input) => `Maximum ${input.maxLength} characters allowed.`,
    rangeUnderflow:  (input) => `Minimum value is ${input.min}.`,
    rangeOverflow:   (input) => `Maximum value is ${input.max}.`,
};

function getErrorMessage(input) {
    const validity = input.validity;

    if (validity.valueMissing)   return errorMessages.valueMissing;
    if (validmatch[input.type] || 'Invalid format.';
    if (validity.patternMismatch) return input.title || errorMessages.patternMismatch;
    if (validity.tooShort)       return errorMessages.tooShort(input);
    if (validity.tooLong)        return errorMessages.tooLong(input);
    if (validity.rangeUnderflow) return errorMessages.rangeUnderflow(input);
    if (validity.rangeOverflow)  return errorMessages.rangeOverflow(input);

    return 'Invalid value.';
}

function showError(input, message) {
    const errorEl = input.nextElementSibling;
    input.setAttribute('aria-invalid', 'true');
    errorEl.textContent = message;
    errorEl.style.display = 'block';
}

function clearError(input) {
    const errorEl = input.nextElementSibling;
    input.removeAttribute('aria-invalid');
    errorEl.textContent = '';
    errorEl.style.display = 'none';
}

        sendContactEmail(req.body);
        res.json({ success: true });
    }
);

Resources

app.post(’/api/contact’, body(’name’).trim().isLength({ min: 2, max: 50 }).escape(), body(’email’).isEmail().normalizeEmail(), body(‘message’).trim().isLength({ min: 10, max: 1000 }).escape(),

(req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(422).json({ errors: errors.array() });
    }

    // Process valid data

valid email address.

Shipping Address
```

Server-Side Validation (Always Required)

Client-side validation can be bypassed. Always validate on the server:


            <input type="password" {...register('confirm')} />
            {errors.confirm && <p>{errors.confirm.message}</p>}

            <button type="submit">Sign Up</button>
        </form>
    );
}

Accessibility Requirements

Form validation must be accessible:

<!-- Associate error messages with inputs -->
<input
    id="email"
    type="email"
    aria-describedby="email-error"
    aria-invalid="true"
    required
>
<span id="email-error" role="alert">
    Please enter a  const { register, handleSubmit, formState: { errors } } = useForm({
        resolver: zodResolver(schema),
    });

    return (
        <form onSubmit={handleSubmit(console.log)}>
            <input {...register('name')} />
            {errors.name && <p>{errors.name.message}</p>}

            <input type="email" {...register('email')} />
            {errors.email && <p>{errors.email.message}</p>}

            <input type="password" {...register('password')} />
            {errors.password && <p>{errors.passwoz.string().min(2, 'Minimum 2 characters').max(50),
    email:    z.string().email('Invalid email address'),
    age:      z.number().min(18, 'Must be 18 or older').max(120),
    password: z.string()
        .min(8, 'Minimum 8 characters')
        .regex(/[A-Z]/, 'Must contain uppercase letter')
        .regex(/[0-9]/, 'Must contain a number'),
    confirm:  z.string(),
}).refine(data => data.password === data.confirm, {
    message: "Passwords don't match",
    path: ['confirm'],
});

function SignupForm() {
  ole="alert">{errors.email.message}</span>
                )}
            </div>

            <button type="submit" disabled={isSubmitting}>
                {isSubmitting ? 'Sending...' : 'Submit'}
            </button>
        </form>
    );
}

With Zod Schema Validation

npm install react-hook-form @hookform/resolvers zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
    name:       id="email"
                    type="email"
                    {...register('email', {
                        required: 'Email is required',
                        pattern: {
                            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
                            message: 'Invalid email address',
                        },
                    })}
                    aria-invalid={errors.email ? 'true' : 'false'}
                />
                {errors.email && (
                    <span r..register('name', {
                        required: 'Name is required',
                        minLength: { value: 2, message: 'Minimum 2 characters' },
                    })}
                    aria-invalid={errors.name ? 'true' : 'false'}
                />
                {errors.name && (
                    <span role="alert">{errors.name.message}</span>
                )}
            </div>

            <div>
                <label htmlFor="email">Email</label>
                <input
                  it,
        formState: { errors, isSubmitting },
    } = useForm();

    const onSubmit = async (data) => {
        await fetch('/api/contact', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data),
        });
    };

    return (
        <form onSubmit={handleSubmit(onSubmit)} noValidate>
            <div>
                <label htmlFor="name">Name</label>
                <input
                    id="name"
                    {.earError(usernameInput);
        }
    } catch (err) {
        console.error('Username check failed:', err);
    }
}, 500);

usernameInput.addEventListener('input', (e) => {
    usernameInput.setCustomValidity('');  // clear while typing
    checkUsername(e.target.value);
});

React Form Validation

npm install react-hook-form
import { useForm } from 'react-hook-form';

function ContactForm() {
    const {
        register,
        handleSubmconst checkUsername = debounce(async (username) => {
    if (username.length < 3) return;

    try {
        const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`);
        const { available } = await response.json();

        if (!available) {
            usernameInput.setCustomValidity('This username is already taken.');
            showError(usernameInput, 'This username is already taken.');
        } else {
            usernameInput.setCustomValidity('');
            cl

.field input:valid:not(:placeholder-shown) {
    border-color: #28a745;
}

.error {
    display: none;
    color: #dc3545;
    font-size: 0.875rem;
    margin-top: 0.25rem;
}

Async Validation (Check Username Availability)

// Debounce to avoid too many API calls
function debounce(fn, delay) {
    let timer;
    return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), delay);
    };
}

const usernameInput = document.getElementById('username');

lectorAll('input, textarea, select');
    let isValid = true;

    inputs.forEach(input => {
        if (!validateField(input)) {
            isValid = false;
        }
    });

    if (isValid) {
        submitForm(new FormData(form));
    } else {
        // Focus the first invalid field
        form.querySelector('[aria-invalid="true"]')?.focus();
    }
});
/* form-validation.css */
.field {
    margin-bottom: 1rem;
}

.field input:invalid:not(:placeholder-shown) {
    border-color: #dc3545;
} return true;
}

// Validate on blur (when user leaves a field)
form.querySelectorAll('input, textarea, select').forEach(input => {
    input.addEventListener('blur', () => validateField(input));
    input.addEventListener('input', () => {
        if (input.getAttribute('aria-invalid')) {
            validateField(input);  // re-validate as user types to clear errors
        }
    });
});

// Validate all on submit
form.addEventListener('submit', (e) => {
    e.preventDefault();

    const inputs = form.querySefunction validateField(input) {
    if (!input.checkValidity()) {
        showError(input, getErrorMessage(input));
        return false;
    }
    clearError(input);
   

Comments