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 Team
Developer Relations
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
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
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.
Share this article