Skip to content
form koi.Start free

Form Koi for React

React contact form
without a custom backend.

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.

Your form → Form Koi

From website form to email notification.

  1. 01 / Your websiteYour fields, styles, and public form endpoint.
  2. 02 / Submission inboxReview the message and its spam status.
  3. 03 / Notification historySee the recipient and follow delivery attempts.
See how delivery works ↗

React form with loading, success and error states.

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.

01

Make sending visible

Disable the form while the request is in progress. An accessible status message announces the outcome without a page reload.

02

Keep the message on failure

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.

03

Retry the same request

The example reuses an idempotency key for unchanged data, reducing accidental duplicates after an uncertain response.

Explore

Copy, connect, send

Copy the React contact form example.

  1. Create a form and verify its recipient in your Form Koi workspace.
  2. Replace the complete example endpoint with the URL from your connection guide.
  3. Keep the honeypot name in sync with your form settings, then submit from your website.
This example uses a placeholder endpoint.

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.

ContactForm.jsx
"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 submission storage and email delivery.

01

Check the website

Test required fields and a real submission from your deployed domain. Configure allowed origins and your native thank-you destination.

Explore
02

Check the inbox

Find the message and check Spam if it is missing. Confirm the chosen recipients and notification history; an accepted submission is not a delivery receipt.

Explore
03

Check the limits

The allowance is shared across your workspace. At the monthly limit the API returns 429; provide a useful alternative way to contact you.

Explore

Frequently asked questions

Contact form
questions.

Does this work in a Vite React app?

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.

Is this a TypeScript component?

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.

How do I enable Turnstile or uploads?

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

Start receiving
form submissions.

Create a free form Read the quickstart