React Contact Form — Build, Validate & Collect Submissions (2026)
A practical guide to building a production-ready React contact form — from basic state handling to validation, submission, and success states. Skip straight to the no-code section if you just need a form live fast.
In this guide
A contact form sounds simple — a few fields, a submit button — but getting it right in React takes more work than it looks. You need controlled inputs, validation that does not annoy users, a submission handler that talks to an API, and clear feedback when things go right or wrong. This guide walks through each piece with complete, copy-pasteable code.
1. What a contact form actually needs
Before writing any code, it helps to define what done looks like. A production contact form needs:
- Controlled inputs wired to React state
- Client-side validation with inline error messages
- A submission handler that sends data somewhere useful
- A loading state so the button does not look frozen
- A success state so the user knows their message was received
- An error state for when the network or API fails
Most tutorials cover the first two and stop. This guide covers all six.
2. The basic contact form with useState
The foundation of any React form is controlled inputs — each input's value is stored in state and updated on every keystroke via onChange. Here is the minimal starting point for a name, email, and message form:
import { useState } from "react";
export default function ContactForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(formData); // send this somewhere
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-md">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full border rounded-lg px-3 py-2 text-sm outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full border rounded-lg px-3 py-2 text-sm outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Message</label>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
rows={4}
className="w-full border rounded-lg px-3 py-2 text-sm outline-none resize-none"
/>
</div>
<button
type="submit"
className="bg-teal-600 text-white font-semibold py-2 px-5 rounded-lg hover:bg-teal-700 transition"
>
Send message
</button>
</form>
);
}This is the skeleton. The handleChange function uses the input's name attribute as the state key, so one function handles all three fields. The handleSubmit prevents the default browser form submission and gives you control over what happens next.
3. Adding validation
Validation runs on submit. A validate function checks the data and returns an errors object. If that object has any keys, you show the errors and stop — you do not submit. If it is empty, you proceed.
const [errors, setErrors] = useState({});
const validate = (data) => {
const errs = {};
if (!data.name.trim()) {
errs.name = "Name is required";
}
if (!data.email.trim()) {
errs.email = "Email is required";
} else if (!/S+@S+.S+/.test(data.email)) {
errs.email = "Enter a valid email address";
}
if (!data.message.trim()) {
errs.message = "Message cannot be empty";
} else if (data.message.trim().length < 10) {
errs.message = "Message must be at least 10 characters";
}
return errs;
};
const handleSubmit = (e) => {
e.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) {
setErrors(errs);
return;
}
setErrors({});
// proceed with submission
};Display errors directly beneath the field they belong to. Clear the error for a field as soon as the user starts fixing it:
// In handleChange — clear the error for the field being edited
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (errors[name]) setErrors((prev) => ({ ...prev, [name]: "" }));
};
// In the JSX — show the error beneath the input
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className={`w-full border rounded-lg px-3 py-2 text-sm outline-none ${
errors.email ? "border-red-400 focus:border-red-400" : "border-gray-200 focus:border-teal-500"
}`}
/>
{errors.email && (
<p className="text-red-500 text-xs mt-1">{errors.email}</p>
)}
</div>The border colour change on invalid fields is important — it gives users a visual signal before they even read the error text.
4. Handling submission
Once validation passes, you need to send the data somewhere. The most common approaches for a contact form are a serverless function (Vercel Edge, Next.js Route Handler) that calls an email API, or a third-party service like Resend, EmailJS, or Formspree.
Option A — Next.js Route Handler + Resend
Create a route handler at app/api/contact/route.js:
// app/api/contact/route.js
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req) {
const { name, email, message } = await req.json();
await resend.emails.send({
from: "no-reply@yourdomain.com",
to: "you@yourdomain.com",
subject: `New message from ${name}`,
html: `<p><strong>From:</strong> ${name} (${email})</p><p>${message}</p>`,
});
return Response.json({ ok: true });
}Then call it from your form's submit handler:
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
setErrors({});
setSubmitting(true);
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!res.ok) throw new Error("Server error");
setSubmitted(true);
} catch {
setServerError("Something went wrong. Please try again.");
} finally {
setSubmitting(false);
}
};Option B — EmailJS (no backend required)
EmailJS lets you send emails directly from the browser without any server. Install it, create a template in the EmailJS dashboard, then call it from your form:
import emailjs from "@emailjs/browser";
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
setSubmitting(true);
try {
await emailjs.send(
"YOUR_SERVICE_ID",
"YOUR_TEMPLATE_ID",
{ name: formData.name, email: formData.email, message: formData.message },
"YOUR_PUBLIC_KEY"
);
setSubmitted(true);
} catch {
setServerError("Failed to send. Please try again.");
} finally {
setSubmitting(false);
}
};5. Success and error states
Once submitted, replace the form with a clear success message. Do not just show a toast and leave the form there — users will wonder if it worked and may submit again.
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [serverError, setServerError] = useState("");
if (submitted) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="w-12 h-12 bg-teal-500 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-lg font-bold text-gray-800">Message sent!</h2>
<p className="text-sm text-gray-500">Thanks for reaching out. We'll get back to you shortly.</p>
<button
onClick={() => { setSubmitted(false); setFormData({ name: "", email: "", message: "" }); }}
className="text-sm text-teal-600 underline underline-offset-2 mt-1"
>
Send another message
</button>
</div>
);
}
// In the form JSX, disable the button while submitting and show any server error:
<>
{serverError && (
<p className="text-sm text-red-500 bg-red-50 border border-red-100 rounded-lg px-4 py-3">
{serverError}
</p>
)}
<button
type="submit"
disabled={submitting}
className="bg-teal-600 text-white font-semibold py-2 px-5 rounded-lg hover:bg-teal-700 transition disabled:opacity-60"
>
{submitting ? "Sending…" : "Send message"}
</button>
</>6. Styling with Tailwind CSS
Here is the complete, polished contact form component with all the pieces assembled. Copy this directly into your project:
"use client";
import { useState } from "react";
const validate = (data) => {
const errs = {};
if (!data.name.trim()) errs.name = "Name is required";
if (!data.email.trim()) errs.email = "Email is required";
else if (!/S+@S+.S+/.test(data.email)) errs.email = "Enter a valid email";
if (!data.message.trim()) errs.message = "Message cannot be empty";
else if (data.message.trim().length < 10) errs.message = "At least 10 characters";
return errs;
};
export default function ContactForm() {
const [formData, setFormData] = useState({ name: "", email: "", message: "" });
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [serverError, setServerError] = useState("");
const inputCls = (field) =>
`w-full border rounded-xl px-3 py-2.5 text-sm outline-none transition ${
errors[field]
? "border-red-400 focus:ring-1 focus:ring-red-400"
: "border-gray-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500"
}`;
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((p) => ({ ...p, [name]: value }));
if (errors[name]) setErrors((p) => ({ ...p, [name]: "" }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
setErrors({});
setSubmitting(true);
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!res.ok) throw new Error();
setSubmitted(true);
} catch {
setServerError("Something went wrong. Please try again.");
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center max-w-md mx-auto">
<div className="w-12 h-12 bg-teal-500 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-lg font-bold text-gray-800">Message sent!</h2>
<p className="text-sm text-gray-500">We'll get back to you as soon as possible.</p>
<button
onClick={() => { setSubmitted(false); setFormData({ name: "", email: "", message: "" }); }}
className="text-sm text-teal-600 underline underline-offset-2"
>
Send another message
</button>
</div>
);
}
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-5 max-w-md mx-auto bg-white border border-gray-100 rounded-2xl shadow-sm p-8"
>
<h2 className="text-xl font-bold text-gray-800">Get in touch</h2>
{["name", "email"].map((field) => (
<div key={field}>
<label className="block text-sm font-medium text-gray-700 mb-1 capitalize">{field}</label>
<input
type={field === "email" ? "email" : "text"}
name={field}
value={formData[field]}
onChange={handleChange}
placeholder={field === "email" ? "you@example.com" : "Your name"}
className={inputCls(field)}
/>
{errors[field] && <p className="text-red-500 text-xs mt-1">{errors[field]}</p>}
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Message</label>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
rows={4}
placeholder="How can we help?"
className={inputCls("message") + " resize-none"}
/>
{errors.message && <p className="text-red-500 text-xs mt-1">{errors.message}</p>}
</div>
{serverError && (
<p className="text-sm text-red-500 bg-red-50 border border-red-100 rounded-lg px-4 py-3">
{serverError}
</p>
)}
<button
type="submit"
disabled={submitting}
className="bg-teal-600 text-white font-semibold py-2.5 px-5 rounded-xl hover:bg-teal-700 transition disabled:opacity-60"
>
{submitting ? "Sending…" : "Send message"}
</button>
</form>
);
}7. Skip the code — publish in minutes
If you do not want to wire up an email API, manage a serverless function, or write validation by hand, ReactForm.co lets you build and publish a contact form visually in a few minutes. You get a shareable link, real response collection, email-format validation on every field, and a dashboard to view and export submissions — no backend required.
You can also export your form as a clean React component and drop it into your own app. The exported code uses standard HTML5 inputs with no external dependencies — just copy it in and style it to match your design system.
Build your contact form without the setup
Design visually, publish with a shareable link, and collect responses — or export clean React code. Free plan, no credit card required.
Open the Form Builder →