Free plan

50 submissions/month · no credit card · View plans

Tutorials 8 min read

React Contact Form: Tutorial with Validation & AJAX Submission

Build a production-ready React contact form with real-time validation, loading states, and AJAX submission - no backend code required.

formvoxo

React + formvoxo

Controlled inputs, real-time validation, loading state, AJAX submission.

React makes forms more powerful - but also more complex. In this tutorial you'll build a full production contact form with controlled inputs, real-time validation, a loading state, and AJAX submission - without writing a single line of backend code.

What we're building

Controlled form with name, email, message
Real-time validation (on blur)
Loading spinner during submission
Success/error states after submit
AJAX - no page redirect
Zero backend code

Step 1 - Get your endpoint

Create a free account at formvoxo.com and create a new form. Copy your endpoint URL (https://formvoxo.com/f/your-token) and replace the placeholder in the code below.

Step 2 - The component

ContactForm.jsx
import { useState } from 'react';

// Replace with your formvoxo endpoint
const ENDPOINT = 'https://formvoxo.com/f/your-token';

function validate(fields) {
  const errors = {};
  if (!fields.name.trim())        errors.name    = 'Name is required.';
  if (!fields.email.trim())       errors.email   = 'Email is required.';
  else if (!/\S+@\S+\.\S+/.test(fields.email))
                                  errors.email   = 'Enter a valid email.';
  if (!fields.message.trim())     errors.message = 'Message is required.';
  else if (fields.message.length < 10)
                                  errors.message = 'At least 10 characters.';
  return errors;
}

export default function ContactForm() {
  const [fields, setFields]   = useState({ name: '', email: '', message: '' });
  const [touched, setTouched] = useState({});
  const [status, setStatus]   = useState('idle'); // idle | loading | success | error

  const errors  = validate(fields);
  const isValid = Object.keys(errors).length === 0;

  const handleChange = (e) =>
    setFields(f => ({ ...f, [e.target.name]: e.target.value }));

  const handleBlur = (e) =>
    setTouched(t => ({ ...t, [e.target.name]: true }));

  const handleSubmit = async (e) => {
    e.preventDefault();
    setTouched({ name: true, email: true, message: true });
    if (!isValid) return;

    setStatus('loading');
    try {
      const res = await fetch(ENDPOINT, {
        method:  'POST',
        headers: {
          'Accept':       'application/json',
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(fields),
      });
      const data = await res.json();
      setStatus(data.success ? 'success' : 'error');
    } catch {
      setStatus('error');
    }
  };

  if (status === 'success') return (
    <div className="success-banner">
      Message sent! We'll be in touch soon.
    </div>
  );

  return (
    <form onSubmit={handleSubmit} noValidate>
      /* name, email, message fields - see explanation below */
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending…' : 'Send message'}
      </button>
    </form>
  );
}

Key decisions explained

Why noValidate?

We add noValidate so the browser doesn't show its own validation popups. We control all validation ourselves for a consistent, branded look.

Why validate on blur, not on change?

Showing an error the moment the user starts typing is annoying. Validating on blur (when they leave the field) catches errors quickly without interrupting input.

Why Accept: application/json?

Sending this header tells formvoxo to return a JSON response ({ "success": true }) instead of doing a page redirect. This enables the inline success state - no full-page navigation in your SPA.

Try it with your own endpoint

Create a free formvoxo account and get your endpoint URL in under a minute.

formvoxo

Start collecting form submissions today

No server, no backend code. Just point your HTML form at your endpoint and you're live.