Build a Contact Form in React with InputHaven
What we're building
A production-ready contact form with:
- Client-side validation
- Loading states and error handling
- Honeypot spam protection
- Success confirmation
- Accessible markup
No backend code required. InputHaven handles everything server-side.
Prerequisites
- A React project (Create React App, Vite, or similar)
- An InputHaven account (free tier is fine)
- A form created in the InputHaven dashboard
Step 1: Basic form structure
Start with a simple form that posts to InputHaven:
function ContactForm() {
return (
<form
action="https://inputhaven.com/api/v1/submit"
method="POST"
>
<input type="hidden" name="_form_id" value="your-form-id" />
<label htmlFor="name">Name</label>
<input type="text" id="name" name="name" required />
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit">Send Message</button>
</form>
);
}This works as-is — the form submits via standard HTML form submission. But for a better user experience, let's handle submission with JavaScript.
Step 2: JavaScript submission with fetch
import { useState } from "react";
function ContactForm() {
const [status, setStatus] = useState("idle");
const [error, setError] = useState("");
async function handleSubmit(e) {
e.preventDefault();
setStatus("submitting");
setError("");
try {
const response = await fetch(
"https://inputhaven.com/api/v1/submit",
{
method: "POST",
body: new FormData(e.target),
headers: { Accept: "application/json" },
}
);
if (response.ok) {
setStatus("success");
e.target.reset();
} else {
const data = await response.json();
setError(data.error || "Something went wrong.");
setStatus("error");
}
} catch {
setError("Network error. Please try again.");
setStatus("error");
}
}
if (status === "success") {
return (
<div role="alert">
<h3>Message sent!</h3>
<p>Thank you for reaching out. We'll get back to you soon.</p>
<button onClick={() => setStatus("idle")}>
Send another message
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="_form_id" value="your-form-id" />
<label htmlFor="name">Name</label>
<input type="text" id="name" name="name" required />
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{error && <p role="alert" style={{ color: "red" }}>{error}</p>}
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Sending..." : "Send Message"}
</button>
</form>
);
}Key points:
- **
Accept: application/json** tells InputHaven to return JSON instead of redirecting - **
FormData** automatically serializes all form fields - Loading state disables the button to prevent double submission
- Error handling shows server errors and network errors
Step 3: Add honeypot spam protection
Add an invisible field that bots will fill in but humans won't:
{/* Add this inside your form */}
<div style={{ position: "absolute", left: "-9999px" }} aria-hidden="true">
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
</div>InputHaven checks the _gotcha field. If it has a value, the submission is rejected as spam.
Step 4: Client-side validation
Add validation before submitting:
function validateForm(formData) {
const name = formData.get("name")?.toString().trim();
const email = formData.get("email")?.toString().trim();
const message = formData.get("message")?.toString().trim();
if (!name || name.length < 2) return "Please enter your name.";
if (!email || !email.includes("@")) return "Please enter a valid email.";
if (!message || message.length < 10) return "Please enter a message (at least 10 characters).";
return null;
}
// In handleSubmit, before the fetch call:
const validationError = validateForm(new FormData(e.target));
if (validationError) {
setError(validationError);
setStatus("error");
return;
}Step 5: Accessibility
Make the form accessible:
- Labels — every input has an associated
<label> - Required fields — use the
requiredattribute andaria-required="true" - Error messages — use
role="alert"so screen readers announce errors - Focus management — on error, focus the first invalid field
- Disabled state — use
aria-disabledalongsidedisabled
Complete example
Here's the full production-ready component:
import { useState, useRef } from "react";
export function ContactForm({ formId }) {
const [status, setStatus] = useState("idle");
const [error, setError] = useState("");
const formRef = useRef(null);
async function handleSubmit(e) {
e.preventDefault();
setStatus("submitting");
setError("");
const formData = new FormData(e.target);
// Client-side validation
const name = formData.get("name")?.toString().trim();
const email = formData.get("email")?.toString().trim();
const message = formData.get("message")?.toString().trim();
if (!name || name.length < 2) {
setError("Please enter your name.");
setStatus("error");
return;
}
if (!email || !email.includes("@")) {
setError("Please enter a valid email address.");
setStatus("error");
return;
}
if (!message || message.length < 10) {
setError("Please enter a message (at least 10 characters).");
setStatus("error");
return;
}
try {
const response = await fetch(
"https://inputhaven.com/api/v1/submit",
{
method: "POST",
body: formData,
headers: { Accept: "application/json" },
}
);
if (response.ok) {
setStatus("success");
e.target.reset();
} else {
const data = await response.json().catch(() => null);
setError(data?.error || "Failed to send. Please try again.");
setStatus("error");
}
} catch {
setError("Network error. Check your connection and try again.");
setStatus("error");
}
}
if (status === "success") {
return (
<div role="alert">
<h3>Message sent!</h3>
<p>Thank you. We'll respond within 24 hours.</p>
<button onClick={() => setStatus("idle")}>
Send another message
</button>
</div>
);
}
return (
<form ref={formRef} onSubmit={handleSubmit} noValidate>
<input type="hidden" name="_form_id" value={formId} />
{/* Honeypot */}
<div style={{ position: "absolute", left: "-9999px" }} aria-hidden="true">
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
</div>
<div>
<label htmlFor="name">Name *</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
placeholder="Jane Doe"
/>
</div>
<div>
<label htmlFor="email">Email *</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
placeholder="jane@example.com"
/>
</div>
<div>
<label htmlFor="message">Message *</label>
<textarea
id="message"
name="message"
required
aria-required="true"
rows={5}
placeholder="How can we help?"
/>
</div>
{error && (
<p role="alert" style={{ color: "red" }}>{error}</p>
)}
<button
type="submit"
disabled={status === "submitting"}
aria-disabled={status === "submitting"}
>
{status === "submitting" ? "Sending..." : "Send Message"}
</button>
</form>
);
}Usage: <ContactForm formId="your-form-id" />
Using @inputhaven/react
For an even simpler integration, use our React package:
npm install @inputhaven/reactimport { InputHavenForm } from "@inputhaven/react";
function ContactPage() {
return (
<InputHavenForm formId="your-form-id">
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send</button>
</InputHavenForm>
);
}The package handles submission, loading states, error handling, and honeypot fields automatically.
Next steps
- Webhooks — forward submissions to Slack or your CRM
- Auto-responses — send a confirmation email to the submitter
- File uploads — accept attachments (Starter plan and above)
- AI spam filtering — enable Claude-powered spam detection