React Guide

React Contact Form (with hooks & fetch)

Build a copy-pasteable React contact form that posts straight to Formigo - no backend of your own, no server route, no fuss. Just useState, fetch, and a form endpoint that actually answers.

1. Grab your form endpoint

Every Formigo form has its own endpoint that looks like https://formigo.io/f/your-form. Your React app posts a plain JSON body to that URL - Formigo stores the submission, emails you, and replies with a tidy 2xx. The endpoint is CORS-enabled, so a client-side fetch() from the browser works out of the box. No proxy route required.

Swap your-form for your real form slug from the Formigo dashboard. Everything else below is ready to paste.

2. The full React contact form component

ContactForm.jsx

Function component, hooks, success / error / rate-limit handling

Here is the whole thing - drop it into a .jsx file and render <ContactForm /> wherever you need it. It handles a successful send, a generic error, and Formigo's 429 rate-limit response, plus two quiet spam fields. We break down each piece right after.

import { useState, useEffect } from "react";

const FORM_ENDPOINT = "https://formigo.io/f/your-form";

export default function ContactForm() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    message: "",
    _formigo_hp: "", // Honeypot: real humans leave this empty
  });
  const [status, setStatus] = useState("idle"); // idle | sending | success | error
  const [errorMsg, setErrorMsg] = useState("");
  const [loadedAt, setLoadedAt] = useState(0);

  // Stamp the moment the form mounted. Bots tend to submit instantly.
  useEffect(() => {
    setLoadedAt(Math.floor(Date.now() / 1000));
  }, []);

  const handleChange = (e) => {
    setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setStatus("sending");
    setErrorMsg("");

    try {
      const response = await fetch(FORM_ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          ...formData,
          _formigo_t: loadedAt, // Unix timestamp from when the form mounted
        }),
      });

      if (response.ok) {
        setStatus("success");
        setFormData({ name: "", email: "", message: "", _formigo_hp: "" });
        setLoadedAt(Math.floor(Date.now() / 1000));
      } else if (response.status === 429) {
        const data = await response.json();
        setStatus("error");
        setErrorMsg(`Whoa there - too many tries. Retry in ${data.retry_after}s.`);
      } else {
        setStatus("error");
        setErrorMsg("Something went sideways. Please try again.");
      }
    } catch (err) {
      setStatus("error");
      setErrorMsg("Network hiccup. Check your connection and retry.");
    }
  };

  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <label htmlFor="name">Name</label>
      <input
        id="name"
        name="name"
        type="text"
        value={formData.name}
        onChange={handleChange}
        required
      />

      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        value={formData.email}
        onChange={handleChange}
        required
      />

      <label htmlFor="message">Message</label>
      <textarea
        id="message"
        name="message"
        rows={5}
        value={formData.message}
        onChange={handleChange}
        required
      />

      {/* Honeypot: hidden from real users, catnip for bots */}
      <input
        type="text"
        name="_formigo_hp"
        value={formData._formigo_hp}
        onChange={handleChange}
        tabIndex={-1}
        autoComplete="off"
        aria-hidden="true"
        style={{ display: "none" }}
      />

      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending..." : "Send message"}
      </button>

      {status === "success" && (
        <p role="status" className="form-ok">
          Thanks! Your message landed safely.
        </p>
      )}

      {status === "error" && (
        <p role="alert" className="form-error">
          {errorMsg}
        </p>
      )}
    </form>
  );
}

3. How it works, step by step

Nothing magic here - just idiomatic modern React. Here is what each part is doing.

State with useState

One formData object holds your fields as a controlled form, and a small status string (idle → sending → success / error) drives the button label and the messages. Keeping status as a single value beats juggling three booleans.

The timestamp with useEffect

On mount we record the current Unix time in loadedAt and send it as _formigo_t. Bots usually post the instant a page loads; a human takes a few seconds to type, so the gap is a handy spam signal.

Posting with fetch

const response = await fetch("https://formigo.io/f/your-form", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ ...formData, _formigo_t: loadedAt }),
});

We send JSON, so we set Content-Type: application/json. (Formigo also accepts application/x-www-form-urlencoded if you would rather post a FormData body - either is fine.) Because the endpoint is CORS-enabled, this runs directly from the browser.

Handling the response

A successful submission is any 2xx, so response.ok is your green light - we show a thank-you and reset the fields. If you hit the rate limiter you get an HTTP 429 with a JSON body like { "retry_after": 30 }, so we read retry_after and tell the user exactly how long to wait. Anything else falls through to a friendly generic error, and a network failure is caught by the surrounding try / catch.

The honeypot

_formigo_hp is a real input hidden with display: none and marked aria-hidden with tabIndex={-1}. People never see or focus it, so it stays empty. Bots that blindly fill every field give themselves away - a non-empty honeypot is a strong spam signal. Together with the timestamp, that is most of your spam gone without a single CAPTCHA.

Using Next.js? This same component works in the App Router as a Client Component - just add "use client" at the top of the file.

FAQ

Common questions about building a React contact form without a backend.

Do I need a backend for a React contact form?

No. The component posts directly to your Formigo endpoint from the browser, so a static React build deployed to any host works. The endpoint is CORS-enabled, so you do not need a proxy or a server of your own.

Does this work with Vite or Create React App?

Yes. The component is plain React with useState and useEffect and no build-tool-specific code, so it works in any React setup that runs in the browser. Next.js has its own guide because the App Router adds Server Actions and Route Handlers as alternatives.

Why set the timestamp in useEffect instead of at submit time?

The timestamp records when the form was rendered, not when it was sent. Formigo compares it against the time the submission arrives, and anything under 2 seconds looks automated. Setting it at submit time would always produce a zero-second gap and defeat the check.

Should the honeypot be a controlled input?

Yes, keep it in the same state object as the other fields so its value is submitted with them and reset after a successful send. It must arrive empty: a filled honeypot adds 100 spam points, which is enough to flag the submission on its own. Hide it with display: none and add tabIndex={-1} and aria-hidden so nobody can tab into it.

How do I handle rate limiting in React?

Check for response.status === 429 before treating the response as an error. Formigo allows 5 submissions per IP per form every 10 minutes and 20 per IP per hour across all forms, and a throttled response carries a JSON body with retry_after, the seconds until the limit resets. Show that number in your error state.

Wire up your React contact form in minutes

Create a free Formigo form, paste your formigo.io/f/ endpoint into the component above, and start collecting submissions today. No backend to build, no spam to wade through.