Make sending visible
Disable the form while the request is in progress. An accessible status message announces the outcome without a page reload.
Form Koi for React
Copy a React JSX component that posts directly to Form Koi. Show loading, success and error states, keep the visitor’s message on failure, and reuse a request ID for unchanged retries. No SDK required.
250 submissions/month free. No credit card required.
This JavaScript component uses React state for feedback, prevents duplicate clicks, and retains the visitor’s message if a request fails. No Form Koi SDK is required.
Disable the form while the request is in progress. An accessible status message announces the outcome without a page reload.
Network failures and API errors leave the fields intact. A timeout explains that receipt could not be confirmed instead of claiming the message was lost.
The example reuses an idempotency key for unchanged data, reducing accidental duplicates after an uncertain response.
ExploreCopy, connect, send
It will not deliver messages until connected to your form. It starts without Turnstile or file uploads. When you enable either, copy the updated example from your workspace.
"use client";
import { useRef, useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
const request = useRef(null);
const submitting = useRef(false);
async function submit(event) {
event.preventDefault();
if (submitting.current) return;
const form = event.currentTarget;
const body = new FormData(form);
submitting.current = true;
setBusy(true);
setStatus("");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
try {
const fingerprint = JSON.stringify([...body.entries()].filter(([name]) => name !== "cf-turnstile-response"));
if (request.current?.fingerprint !== fingerprint) {
request.current = { fingerprint, id: crypto.randomUUID() };
}
const response = await fetch("https://api.formkoi.com/f/YOUR_PUBLIC_FORM_KEY", {
method: "POST",
headers: { "Idempotency-Key": request.current.id },
body,
signal: controller.signal,
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.success !== true) {
throw new Error(result?.error?.message || "We could not confirm receipt. Your details are still here. Please try again.");
}
setStatus("Thanks! Your message has been received.");
form.reset();
request.current = null;
} catch (error) {
setStatus(controller.signal.aborted
? "This is taking longer than expected. We could not confirm receipt. Your details are still here; please try again."
: error instanceof Error && !(error instanceof TypeError)
? error.message
: "We could not complete the request. Your details are still here. Please try again.");
} finally {
clearTimeout(timeout);
submitting.current = false;
setBusy(false);
}
}
return (
<form onSubmit={submit}>
<fieldset disabled={busy} aria-label="Contact details" style={{ border: 0, margin: 0, padding: 0 }}>
<label>Your name <input name="name" autoComplete="name" required /></label>
<label>Email address <input name="email" type="email" autoComplete="email" required /></label>
<label>Your message <textarea name="message" required /></label>
<input name="botcheck" type="text" tabIndex={-1} autoComplete="off" style={{ display: "none" }} hidden />
</fieldset>
<button disabled={busy}>{busy ? "Sending…" : "Send message"}</button>
<p role="status">{status}</p>
</form>
);
}Style the fields to fit your website. For a complete HTML document with CSS, explore the form templates.
Before you publish
Test required fields and a real submission from your deployed domain. Configure allowed origins and your native thank-you destination.
ExploreFind the message and check Spam if it is missing. Confirm the chosen recipients and notification history; an accepted submission is not a delivery receipt.
ExploreThe allowance is shared across your workspace. At the monthly limit the API returns 429; provide a useful alternative way to contact you.
ExploreFrequently asked questions
Yes. Save the example as ContactForm.jsx, import it into your page, and replace the complete endpoint URL. The use client directive is only needed by frameworks with a server/client component boundary.
The copyable example is JSX. Use a .jsx file, or add event, ref, and error types when adapting it to TSX. The browser request contract is the same.
Configure the feature in your form settings and copy the updated React example from your workspace. Turnstile requires a widget and token handling; attachments require multipart submission and the upload limits.
Start with Form Koi Free