Build a fully functional contact form in React with hooks, validation, and email notifications. No backend server or API routes needed.
TL;DR
To add a contact form to a React app without a backend, use FormsList as your form endpoint. Submit form data via fetch to `https://formslist.com/f/your-form-id` and FormsList handles storage, notifications, and spam protection. Works with Create React App, Vite, and all React frameworks.
Create a free FormsList account, add a new form, and copy the endpoint URL. This endpoint accepts POST requests with form data and handles storage, validation, and email delivery on the server side. FormsList endpoints accept both FormData and JSON payloads, so you can use whichever format fits your React setup. For most contact forms, JSON is the cleaner choice in React because you are already managing form state as a JavaScript object. The endpoint returns standard HTTP status codes — 200 for success, 400 for validation errors, 429 for rate limiting — so your error handling can be precise. Store the endpoint URL in an environment variable rather than hardcoding it in your component. If you are using Vite, create a .env file with VITE_FORMSLIST_URL. For Create React App, use REACT_APP_FORMSLIST_URL. This keeps your configuration separate from your code and makes it easy to swap between test and production endpoints.
Create a new component that uses useState to track field values and submission state. The form collects name, email, and message fields. Using controlled inputs gives you full control over validation and UX before the data is sent. The controlled component pattern means every input value is stored in React state and updated via onChange handlers. This gives you access to the current field values at any point, which is essential for real-time validation, conditional rendering, and form analytics. The tradeoff is slightly more code compared to uncontrolled inputs with useRef, but the flexibility is worth it for any form that needs validation or dynamic behavior. The component tracks four states: idle (form is ready for input), sending (request is in flight), sent (submission succeeded), and error (something went wrong). This state machine approach prevents impossible states — for example, the form cannot be both sending and in an error state simultaneously. The submit button is disabled during the sending state to prevent double submissions, which is one of the most common form bugs in React applications.
import { useState, ChangeEvent, FormEvent } from "react";
interface FormFields {
name: string;
email: string;
message: string;
}
export default function ContactForm() {
const [fields, setFields] = useState<FormFields>({ name: "", email: "", message: "" });
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFields({ ...fields, [e.target.name]: e.target.value });
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setStatus("sending");
try {
const res = await fetch("https://formslist.com/f/YOUR_FORM_HASH", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fields),
});
if (res.ok) {
setStatus("sent");
setFields({ name: "", email: "", message: "" });
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 480 }}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" value={fields.name} onChange={handleChange} required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" value={fields.email} onChange={handleChange} required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={4} value={fields.message} onChange={handleChange} required />
</div>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
{status === "sent" && <p style={{ color: "green" }}>Thank you! Your message has been sent.</p>}
{status === "error" && <p style={{ color: "red" }}>Something went wrong. Please try again.</p>}
</form>
);
}Enhance the form with inline validation before submission. Check that the email format is valid and the message meets a minimum length. This improves user experience by catching mistakes early, while FormsList still validates on the server side as a safety net. The validate function below returns an object mapping field names to error messages. If the object is empty, all fields are valid. Call this function in your handleSubmit before making the fetch request. You can also call it on individual fields during the onChange or onBlur events to provide real-time feedback as the user fills out the form. The onBlur approach is generally preferred because it waits until the user has finished typing in a field before showing an error, whereas onChange validation can feel aggressive and distracting. For email validation, a simple regex check catches obvious formatting mistakes. Do not try to write a regex that validates every possible valid email address — the RFC specification is extremely complex and no client-side regex covers all edge cases. A basic pattern that checks for text, an @ symbol, and a domain with a dot is sufficient for UX purposes. The real email validation happens when FormsList delivers the notification and the recipient either receives it or does not.
function validate(fields: FormFields): Record<string, string> {
const errors: Record<string, string> = {};
if (fields.name.trim().length < 2) errors.name = "Name must be at least 2 characters.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email)) errors.email = "Enter a valid email address.";
if (fields.message.trim().length < 10) errors.message = "Message must be at least 10 characters.";
return errors;
}
// Inside handleSubmit, before sending:
const errors = validate(fields);
if (Object.keys(errors).length > 0) {
// Display errors to the user
setFieldErrors(errors);
setStatus("idle");
return;
}If your React project uses Tailwind CSS, you can style the contact form with utility classes for a clean, professional appearance without writing any custom CSS. Tailwind works well with React because you apply styles directly to JSX elements, keeping your styling co-located with your markup. The example below shows how to transform the unstyled form into a polished component. The key Tailwind classes to know for forms are: space-y-4 for consistent vertical spacing between fields, block w-full for making inputs span the full width, rounded-md border border-gray-300 for clean input borders, px-3 py-2 for comfortable input padding, and focus:ring-2 focus:ring-blue-500 focus:border-blue-500 for accessible focus indicators. The disabled:opacity-50 class on the submit button provides visual feedback during the sending state. If you are not using Tailwind, you can achieve the same result with CSS modules or styled-components. The important design principles are the same regardless of your styling approach: generous padding inside inputs so they are easy to tap on mobile, clear visual distinction between labels and inputs, visible focus states for keyboard navigation, and a button that looks obviously clickable. Keep the form width constrained with max-w-md or similar to prevent it from stretching too wide on large screens, which makes the form harder to scan visually.
// Tailwind CSS styled contact form
export default function ContactForm() {
// ... useState and handlers from previous steps ...
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-md mx-auto">
<div>
<label htmlFor="name" className="block text-sm font-semibold text-gray-700 mb-1">
Name
</label>
<input
type="text"
id="name"
name="name"
value={fields.name}
onChange={handleChange}
required
className="block w-full rounded-md border border-gray-300 px-3 py-2
shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500
placeholder:text-gray-400"
placeholder="Jane Smith"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
value={fields.email}
onChange={handleChange}
required
className="block w-full rounded-md border border-gray-300 px-3 py-2
shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500
placeholder:text-gray-400"
placeholder="jane@example.com"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-semibold text-gray-700 mb-1">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
value={fields.message}
onChange={handleChange}
required
className="block w-full rounded-md border border-gray-300 px-3 py-2
shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500
placeholder:text-gray-400 resize-y"
placeholder="How can we help?"
/>
</div>
<button
type="submit"
disabled={status === "sending"}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-sm font-semibold
text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500
focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
{status === "sent" && (
<p className="text-green-600 text-sm font-medium">Thank you! Your message has been sent.</p>
)}
{status === "error" && (
<p className="text-red-600 text-sm font-medium">Something went wrong. Please try again.</p>
)}
</form>
);
}If your contact form receives a high volume of submissions, adding reCAPTCHA provides an extra layer of spam protection beyond what FormsList's built-in spam filtering already provides. Google reCAPTCHA v3 is the best choice for React applications because it runs invisibly in the background and does not interrupt the user experience with checkbox challenges or image puzzles. To integrate reCAPTCHA v3, install the react-google-recaptcha-v3 package and wrap your app or form component with the GoogleReCaptchaProvider. When the user submits the form, call executeRecaptcha to generate a token, then include that token in your form data. FormsList can verify the token automatically if you add your reCAPTCHA secret key in the form settings, or you can verify it on your own backend. The reCAPTCHA v3 score ranges from 0.0 (very likely a bot) to 1.0 (very likely a human). A threshold of 0.5 is a reasonable starting point — submissions scoring below 0.5 are flagged as suspicious. You can adjust this threshold in your FormsList dashboard based on your traffic patterns. If you are getting false positives (real users being blocked), lower the threshold. If spam is still getting through, raise it. FormsList also has its own AI-powered spam scoring that works independently of reCAPTCHA, so the two systems complement each other.
// Install: npm install react-google-recaptcha-v3
import { GoogleReCaptchaProvider, useGoogleReCaptcha } from "react-google-recaptcha-v3";
function ContactFormInner() {
const { executeRecaptcha } = useGoogleReCaptcha();
// ... other state from previous steps ...
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!executeRecaptcha) return;
setStatus("sending");
const recaptchaToken = await executeRecaptcha("contact_form");
try {
const res = await fetch("https://formslist.com/f/YOUR_FORM_HASH", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...fields, "g-recaptcha-response": recaptchaToken }),
});
// ... handle response ...
} catch {
setStatus("error");
}
}
return <form onSubmit={handleSubmit}>{/* ... form fields ... */}</form>;
}
// Wrap with the provider in your app or page:
export default function ContactForm() {
return (
<GoogleReCaptchaProvider reCaptchaKey="YOUR_RECAPTCHA_SITE_KEY">
<ContactFormInner />
</GoogleReCaptchaProvider>
);
}Import and render ContactForm in your app. Submit a test entry to verify everything works end to end. In the FormsList dashboard, enable email forwarding and optionally connect Slack or webhook integrations to receive real-time alerts when someone contacts you. Test the form thoroughly before going live. Submit with valid data and confirm the submission appears in your FormsList dashboard. Test validation by submitting with empty fields, an invalid email, and a very short message. Test the error state by temporarily changing the endpoint URL to an invalid one. Test on mobile devices to make sure the form layout is responsive and inputs are easy to tap. Once everything works, configure your production notifications. Enable email forwarding to your inbox, set up a Slack webhook if your team uses Slack, and consider enabling auto-responder emails so users get immediate confirmation. FormsList also provides submission analytics in the dashboard, showing you trends like submission volume over time, most common times of day for submissions, and geographic distribution of your users.
import ContactForm from "./ContactForm";
function App() {
return (
<div className="App">
<h1>Get in Touch</h1>
<ContactForm />
</div>
);
}
export default App;Learn how to add a working contact form to your Next.js application in minutes. No backend code required — just create your form, point it at FormsList, and start receiving submissions by email.
Learn moreLearn the best practices for validating form data on both the client and server side. Improve user experience, reduce errors, and keep your data clean.
Learn moreLearn how to process form submissions on any website without writing server-side code. Use a form backend service to receive, store, and forward submissions by email.
Learn moreSet up your form backend in under a minute. No server required, no complex configuration — just a simple endpoint for your forms.