Tutorials

How to build a contact form in React

March 28, 20268 min read
Building a contact form in React

You have a Vite React app. You need a contact form. The first instinct is to reach for something like Express or a serverless function to handle the POST. But this isn't Next.js. There are no API routes baked in, and building your own backend just for a contact form is more infrastructure than the problem warrants.

There's a cleaner path. Let me show you.

Note

If you're using Next.js specifically, see How to build a contact form in Next.js instead. That post covers Route Handlers, Server Actions, and Next.js-specific patterns.

The options, briefly

When building a contact form in a plain React app, you have three realistic paths:

  1. Your own API: an Express server, a Netlify function, a Vercel serverless route. You own the backend code, the email sending, the spam filtering, and the storage.
  2. A React form library: handles client-side state and validation, but you still need somewhere to send the data.
  3. A form backend service: you point your form at a hosted endpoint. Submissions are stored, spam-filtered, and forwarded to you. No backend to write or maintain.

For most React apps on static hosting (Vercel, Netlify, GitHub Pages, Cloudflare Pages), option 3 is the right call. This guide covers that approach using Formtorch, though the fetch pattern works with any form backend service.

The simplest version: plain HTML

If your contact section is on a mostly static page and you don't care about loading or success states, a plain HTML form works fine in a React component. You don't need JavaScript to handle the submission:

// components/ContactForm.tsx
export function ContactForm() {
  return (
    <form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
      <input name="name" type="text" placeholder="Your name" required />
      <input name="email" type="email" placeholder="Your email" required />
      <textarea name="message" placeholder="Message" required></textarea>
      <button type="submit">Send message</button>
    </form>
  );
}

The browser handles the submission natively. Formtorch redirects to a default thank-you page after. If you want to redirect to your own page:

<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
  <input
    type="hidden"
    name="_redirect"
    value="https://yoursite.com/thank-you"
  />
  {/* your fields */}
</form>

For a lot of use cases, this is enough. But React apps usually want something more interactive, with loading and success states built in.

A real React contact form

Here's a complete component with loading, success, and error states. No "use client" directive needed here — that's a Next.js concept. In a plain React app, every component is a client component by default.

// components/ContactForm.tsx
import { useState } from "react";

type FormState = "idle" | "loading" | "success" | "error";

export function ContactForm() {
  const [state, setState] = useState<FormState>("idle");

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setState("loading");

    try {
      const data = new FormData(e.currentTarget);
      const res = await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
        method: "POST",
        headers: { "X-Requested-With": "XMLHttpRequest" },
        body: data,
      });

      if (!res.ok) throw new Error("Submission failed");
      setState("success");
    } catch {
      setState("error");
    }
  }

  if (state === "success") {
    return (
      <div>
        <h3>Message sent.</h3>
        <p>Thanks for reaching out. I'll get back to you soon.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" required />
      </div>

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

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

      {state === "error" && <p>Something went wrong. Please try again.</p>}

      <button type="submit" disabled={state === "loading"}>
        {state === "loading" ? "Sending…" : "Send message"}
      </button>
    </form>
  );
}

A few things worth noting:

X-Requested-With: XMLHttpRequest: this header tells Formtorch to respond with JSON instead of an HTML redirect. Without it, a 200 response still works, but you'd get HTML back which isn't useful in a fetch handler.

FormData over JSON: FormData works with any input type including file uploads, and you don't need to manually list every field. Add or rename a field later and the submission still works without touching this code.

disabled={state === "loading"}: prevents double-submissions when someone clicks twice.

Keeping the endpoint out of your source code

Hardcoding a Formtorch URL in your component is fine for a quick prototype, but if you're committing this to git, you probably don't want a public form ID embedded in your source. Vite makes this easy with environment variables.

Create a .env file at your project root:

VITE_FORM_ENDPOINT=https://formtorch.com/f/abc123

Then reference it in your component:

const res = await fetch(import.meta.env.VITE_FORM_ENDPOINT, {
  method: "POST",
  headers: { "X-Requested-With": "XMLHttpRequest" },
  body: data,
});

A few things to know about Vite env variables:

  • Variables must be prefixed with VITE_ to be exposed to your frontend bundle. Others are server-only and will be undefined at runtime.
  • You access them via import.meta.env.VITE_*, not process.env.*. This is different from Next.js (NEXT_PUBLIC_* and process.env.NEXT_PUBLIC_*).
  • Add .env to your .gitignore and commit a .env.example with the variable name but not the value.
Tip

If you're deploying to Vercel or Netlify, add VITE_FORM_ENDPOINT to your environment variable settings in the hosting dashboard. The build process picks it up there.

Adding client-side validation

The form above relies on the browser's built-in required validation. If you want to control the error presentation yourself, here's how to layer in a custom validate() function:

// components/ContactForm.tsx
import { useState } from "react";

type FormState = "idle" | "loading" | "success" | "error";
type Errors = Partial<Record<"name" | "email" | "message", string>>;

function validate(data: FormData): Errors {
  const errors: Errors = {};

  if (!data.get("name")) errors.name = "Name is required.";

  const email = String(data.get("email") ?? "");
  if (!email) errors.email = "Email is required.";
  else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
    errors.email = "Enter a valid email address.";

  if (!data.get("message")) errors.message = "Message is required.";

  return errors;
}

export function ContactForm() {
  const [state, setState] = useState<FormState>("idle");
  const [errors, setErrors] = useState<Errors>({});

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);

    const errs = validate(data);
    if (Object.keys(errs).length > 0) {
      setErrors(errs);
      return;
    }

    setErrors({});
    setState("loading");

    try {
      const res = await fetch(import.meta.env.VITE_FORM_ENDPOINT, {
        method: "POST",
        headers: { "X-Requested-With": "XMLHttpRequest" },
        body: data,
      });
      if (!res.ok) throw new Error();
      setState("success");
    } catch {
      setState("error");
    }
  }

  if (state === "success") {
    return <p>Thanks for reaching out. I'll get back to you soon.</p>;
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" />
        {errors.name && <span>{errors.name}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {errors.email && <span>{errors.email}</span>}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" rows={5} />
        {errors.message && <span>{errors.message}</span>}
      </div>

      {state === "error" && <p>Something went wrong. Please try again.</p>}

      <button type="submit" disabled={state === "loading"}>
        {state === "loading" ? "Sending…" : "Send message"}
      </button>
    </form>
  );
}

The noValidate attribute on the form disables the browser's built-in validation UI so you control how and where errors appear.

Using react-hook-form

For more complex forms with dynamic fields, conditional sections, or multi-step flows, react-hook-form integrates cleanly with the same fetch pattern:

// components/ContactForm.tsx
import { useForm } from "react-hook-form";

type Fields = {
  name: string;
  email: string;
  message: string;
};

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<Fields>();

  async function onSubmit(data: Fields) {
    const formData = new FormData();
    Object.entries(data).forEach(([k, v]) => formData.append(k, v));

    const res = await fetch(import.meta.env.VITE_FORM_ENDPOINT, {
      method: "POST",
      headers: { "X-Requested-With": "XMLHttpRequest" },
      body: formData,
    });

    if (!res.ok) throw new Error("Submission failed");
  }

  if (isSubmitSuccessful) {
    return <p>Message sent. Talk soon.</p>;
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name", { required: "Name is required" })} />
      {errors.name && <span>{errors.name.message}</span>}

      <input
        type="email"
        {...register("email", { required: "Email is required" })}
      />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register("message", { required: "Message is required" })} />
      {errors.message && <span>{errors.message.message}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Sending…" : "Send message"}
      </button>
    </form>
  );
}
Tip

When react-hook-form throws from onSubmit to signal a server error, isSubmitSuccessful stays false. You can check formState.submitCount to detect repeated failures and show a fallback message.

Where to get your form ID

You'll need a Formtorch account and a form created in the dashboard. The endpoint appears immediately when you create the form.

Create a project

Sign in at formtorch.com and click New Project. Projects are containers for your forms.

Create a form

Inside your project, click New Form. Give it a name. The endpoint URL appears immediately.

Copy the endpoint

Your endpoint looks like https://formtorch.com/f/abc123. Set this as VITE_FORM_ENDPOINT in your .env file.

Set up notification emails

Go to Form Settings → Notifications and add your email. Confirm it, and you'll get an email for each new submission.

What about spam?

Formtorch runs TorchWarden on every submission. It checks for common spam signals (bots filling hidden fields, rapid-fire identical submissions, keyword patterns) without adding friction for real users. You don't configure anything; it's on by default.

If you're building a custom backend instead, a honeypot field is the most practical starting point: a hidden input that real users never fill, but bots usually do.

{
  /* Add inside your form. Hide with CSS, not type="hidden" */
}
<input
  name="_honeypot"
  tabIndex={-1}
  autoComplete="off"
  style={{ display: "none" }}
/>;

For a full breakdown of what makes forms attractive to bots and which defenses hold up, see How to prevent spam in contact forms.

Wrapping up

The short version:

  • For a simple static page: a plain HTML form pointing at a Formtorch endpoint
  • For a React app: fetch with FormData and a "loading" | "success" | "error" state
  • For complex forms: react-hook-form wraps around the same fetch pattern
  • For the endpoint URL: a Vite env variable (import.meta.env.VITE_FORM_ENDPOINT) keeps it out of your source code

You don't need a backend to have a working, spam-filtered contact form with email notifications. If you're curious about what that backend would involve if you built it yourself, The hidden complexity of form handling walks through what's actually under the hood.

Need a form endpoint?

Create a free Formtorch account and get your endpoint URL in under two minutes.

Related posts