Log in

React contact form

A single component with one piece of state for the request and none at all for the fields. React does not need to control every input to submit a form — FormData reads them straight off the DOM node, which is both less code and fewer re-renders.

The rendered result. The code below is the source — it is not run inside this preview.

Replace YOUR_FORM_ID with a form of your own and this sends to your inbox.

Create your form
ContactForm.tsx
import { useState } from "react";
import "./ContactForm.css";

type Status = "idle" | "sending" | "sent" | "error";

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

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    setStatus("sending");

    try {
      const res = await fetch("https://submit.formpost.ai/YOUR_FORM_ID", {
        method: "POST",
        headers: { Accept: "application/json" },
        // Not JSON: FormData carries file inputs and repeated names as-is.
        body: new FormData(form),
      });

      const json = await res.json();
      if (json.success) {
        form.reset();
        setStatus("sent");
      } else {
        setError(json.message);
        setStatus("error");
      }
    } catch {
      setError("Could not reach the server. Please try again.");
      setStatus("error");
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <h2>Contact us</h2>

      <label htmlFor="name">Name</label>
      <input id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={4} required />

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

      <p role="status" aria-live="polite" className={status === "error" ? "bad" : "ok"}>
        {status === "sent" && "Thanks — we will be in touch."}
        {status === "error" && error}
      </p>
    </form>
  );
}
ContactForm.css
body {
  margin: 0;
  padding: 32px 16px;
  background: #fbfbfc;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  color: #18181b;
}

form {
  display: flex;
  flex-direction: column;
  max-width: 420px;
  margin: 0 auto;
}

h2 {
  margin: 0 0 18px;
  font-size: 20px;
}

label {
  margin-bottom: 6px;
  font-size: 13px;
  font-weight: 600;
  color: #3f3f46;
}

input,
textarea {
  box-sizing: border-box;
  font: inherit;
  margin-bottom: 16px;
  padding: 10px 12px;
  color: #18181b;
  background: #fff;
  border: 1px solid #d4d4d8;
  border-radius: 8px;
}

input:focus,
textarea:focus {
  border-color: #2563eb;
  outline: 2px solid rgba(37, 99, 235, 0.2);
}

textarea {
  resize: vertical;
}

button {
  padding: 11px 16px;
  font: inherit;
  font-weight: 600;
  color: #fff;
  background: #2563eb;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

button:disabled {
  background: #93b4f5;
  cursor: default;
}

/* The status line, shared by all three JavaScript versions. Keyed on the role
   rather than a class, so it styles the element that is already announcing
   itself to a screen reader. */
[role="status"] {
  margin: 14px 0 0;
  font-size: 14px;
  text-align: center;
}

[role="status"]:empty {
  display: none;
}

[role="status"].ok {
  color: #16a34a;
}

[role="status"].bad {
  color: #dc2626;
}

Uncontrolled inputs are the right default here

The instinct is a useState per field and a value/onChange pair on each input. For a contact form that buys you nothing: you do not need the values until submit, and you have just made every keystroke re-render the whole component. new FormData(event.currentTarget) reads them all at the moment you actually care.

Reach for controlled inputs when you need live validation as somebody types, a character counter, or a field whose value depends on another. Not before.

Capture the form node before you await

const form = event.currentTarget;  // <- before the await
setStatus("sending");
const res = await fetch(/* ... */);
form.reset();                       // still valid

React pools nothing in modern versions, but currentTarget is still nulled once the handler yields. Reading event.currentTarget after an await gives you null and a runtime error on what looks like a perfectly ordinary line. Assign it to a local first.

One status union, not four booleans

isSending, isSent, isError and hasSubmitted as separate booleans can represent states that cannot happen, and eventually will. A single union of idle, sending, sent and error makes the impossible combinations unrepresentable and the JSX much easier to read.

Do not hide the endpoint

It belongs in the client bundle. An endpoint can only deliver to the address its form was set up for, so exposing it gains an attacker nothing — and routing the submission through your own API route just to keep it secret adds a hop, a cold start and a thing to maintain. Put it in a public environment variable if you want it configurable per deployment.

Using Next.js? A Server Action does the same job without shipping any of this to the browser. There is a full example on the Next.js contact form page.

Questions about this contact form

Do I need react-hook-form or Formik for a contact form?
No. Those libraries earn their keep on long forms with cross-field validation and complex error states. A name, an email and a message need one submit handler and FormData.
Why is event.currentTarget null in my handler?
You read it after an await. Assign it to a local variable on the first line of the handler, before any asynchronous work, and use that instead.
Is it safe to put the form endpoint in client-side code?
Yes — it is designed to be public. It only delivers to the address the form was configured for. Turn on domain locking if you want to stop other origins posting to it.
How do I add a file upload to a React contact form?
Add an input of type file and keep posting FormData — it carries the file automatically. This is the main reason not to convert the FormData to JSON before sending.

Related examples

Or go back to all 21 contact form examples.

Give this form an endpoint

Formpost takes the submission, emails it to you and keeps a searchable copy. Free for 250 messages a month, with unlimited forms and no card.