Comparison

React contact form component

Four React components you can paste into any React app: a contact form in JSX and in TypeScript, a newsletter signup and a feedback form with a 1 to 5 score. Each one posts to Formgong with fetch and shows the result to the visitor.

Short answer: Paste ContactForm.jsx, replace fk_your_access_key with the key of a free Formgong form, and render <ContactForm />. Messages arrive by email and in your Formgong inbox.

Contact form component (JSX)

This is the version most people need. It works with Vite, Create React App, Remix or any other React 18 or 19 setup, because it uses nothing beyond useState and fetch. The button is disabled while the message is on its way, so a double click does not send it twice.

Formgong answers with JSON when the request asks for it (Accept: application/json). The component checks result.success === true before it clears the form. A response with status 200 is not enough on its own, and that check is the line most copied snippets leave out.

ContactForm.jsx
import { useState } from "react";

// The key from your Formgong form (it is public, like a form action URL).
const ACCESS_KEY = "fk_your_access_key";

export default function ContactForm() {
  const [status, setStatus] = useState("idle"); // idle | sending | sent | error
  const [notice, setNotice] = useState("");

  async function handleSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    data.set("access_key", ACCESS_KEY);
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await response.json();
      if (result.success === true) {
        form.reset();
        setStatus("sent");
        setNotice("Thanks! Your message has been sent.");
      } else {
        setStatus("error");
        setNotice(result.message || "Not sent. Please try again.");
      }
    } catch {
      setStatus("error");
      setNotice("Not sent: check your connection and try again.");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <label>
        Name
        <input type="text" name="name" autoComplete="name" required />
      </label>
      <label>
        Email
        <input type="email" name="email" autoComplete="email" required />
      </label>
      <label>
        Message
        <textarea name="message" rows={5} required />
      </label>
      {/* Honeypot: people never see it, simple bots fill it in. */}
      <div aria-hidden="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send message"}
      </button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

The same component in TypeScript

Identical behaviour, with the event and the response typed. Save it as ContactForm.tsx.

ContactForm.tsx
import { useState, type FormEvent } from "react";

const ACCESS_KEY = "fk_your_access_key";

type Status = "idle" | "sending" | "sent" | "error";
type FormgongResult = { success: boolean; message?: string };

export default function ContactForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [notice, setNotice] = useState("");

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    data.set("access_key", ACCESS_KEY);
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result: FormgongResult = await response.json();
      if (result.success === true) {
        form.reset();
        setStatus("sent");
        setNotice("Thanks! Your message has been sent.");
      } else {
        setStatus("error");
        setNotice(result.message || "Not sent. Please try again.");
      }
    } catch {
      setStatus("error");
      setNotice("Not sent: check your connection and try again.");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <label>
        Name
        <input type="text" name="name" autoComplete="name" required />
      </label>
      <label>
        Email
        <input type="email" name="email" autoComplete="email" required />
      </label>
      <label>
        Message
        <textarea name="message" rows={5} required />
      </label>
      {/* Honeypot: people never see it, simple bots fill it in. */}
      <div aria-hidden="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send message"}
      </button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

Newsletter signup

One email field and a hidden _subject, so the notification email says what it is. Formgong keeps the addresses in the form's inbox, and you can export them as CSV or forward each one to Mailchimp or another list through Zapier, Make or a webhook.

NewsletterSignup.jsx
import { useState } from "react";

const ACCESS_KEY = "fk_your_access_key";

export default function NewsletterSignup() {
  const [status, setStatus] = useState("idle");
  const [notice, setNotice] = useState("");

  async function handleSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    data.set("access_key", ACCESS_KEY);
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await response.json();
      if (result.success === true) {
        form.reset();
        setStatus("sent");
        setNotice("You're subscribed. Thanks!");
      } else {
        setStatus("error");
        setNotice(result.message || "Not sent. Please try again.");
      }
    } catch {
      setStatus("error");
      setNotice("Not sent: check your connection and try again.");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="newsletter-form">
      <input type="hidden" name="_subject" value="New newsletter signup" />
      <label htmlFor="newsletter-email">Get new posts by email</label>
      <input id="newsletter-email" type="email" name="email" autoComplete="email" placeholder="you@example.com" required />
      {/* Honeypot: people never see it, simple bots fill it in. */}
      <div aria-hidden="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Subscribing…" : "Subscribe"}
      </button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

Feedback form with a score

Radio buttons for the score and an optional email for people who want a reply. The score arrives as a field called rating.

FeedbackForm.jsx
import { useState } from "react";

const ACCESS_KEY = "fk_your_access_key";
const SCORES = ["1", "2", "3", "4", "5"];

export default function FeedbackForm() {
  const [status, setStatus] = useState("idle");
  const [notice, setNotice] = useState("");

  async function handleSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    data.set("access_key", ACCESS_KEY);
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await response.json();
      if (result.success === true) {
        form.reset();
        setStatus("sent");
        setNotice("Thanks for the feedback!");
      } else {
        setStatus("error");
        setNotice(result.message || "Not sent. Please try again.");
      }
    } catch {
      setStatus("error");
      setNotice("Not sent: check your connection and try again.");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="feedback-form">
      <input type="hidden" name="_subject" value="New feedback" />
      <fieldset>
        <legend>How was your experience?</legend>
        {SCORES.map((score) => (
          <label key={score}>
            <input type="radio" name="rating" value={score} required /> {score}
          </label>
        ))}
      </fieldset>
      <label>
        What should we change?
        <textarea name="message" rows={4} />
      </label>
      <label>
        Email (only if you want a reply)
        <input type="email" name="email" autoComplete="email" />
      </label>
      {/* Honeypot: people never see it, simple bots fill it in. */}
      <div aria-hidden="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send feedback"}
      </button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

Styling

The components have class names (contact-form, newsletter-form, feedback-form) and no styles of their own, so they take your site's CSS. If you use Tailwind, the Tailwind contact form template has the same component with classes already on it.

How we tested this code

We pasted each snippet unchanged into a fresh project (React 19.3 with Vite 8), filled in a real access key, and submitted it in Chromium three times against a local copy of Formgong. With the right key the form showed its success text and exactly one submission was stored, not marked as spam. With a key Formgong does not know, the form showed Formgong's own error, "That access key does not exist.", and nothing was stored. With the request cut off, the form said it was not sent. None of the snippets says "sent" before Formgong answers success: true.

Questions

Is it safe to put the access key in front-end code?

Yes. The key only lets someone send a submission to your form, the same thing your page already does. It cannot read submissions or change settings. If you want it out of the page source anyway, add your domain under allowed domains in the form settings, or post through your own server.

Do I need react-hook-form or Formik?

Not for a form this size. The browser checks required fields and email format before the submit handler runs. Add a form library when you need cross-field rules or many steps.

Can I use this in Next.js?

Yes, add "use client" at the top. The Next.js contact form template page also has a route handler and a Server Action version.

What is the hidden botcheck field?

A honeypot. It sits off screen, so people never fill it, while simple bots fill every field they find. Formgong marks a submission with it filled in as spam, so it does not reach your email. Keep the name botcheck.

Sources

Checked on 16.10.2026 against these public pages: React: <form> and form actions, MDN: Using FormData objects.

← Formgong