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.
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.
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>
);
}
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.
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.