Back to blog
TutorialReacttutorialcontact form

Build a Contact Form in React with InputHaven

10 min read

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 required attribute and aria-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-disabled alongside disabled

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/react
import { 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

Ready to try InputHaven?

500 free submissions/month. No credit card required.

Get Started Free