Back to blog

    React Contact Form Example (with Hooks & Validation)

    Build a React contact form from scratch with useState, validation, loading states, and error handling. Works with any form backend including Formboost.

    React Contact Form Example (with Hooks & Validation)

    This guide shows three ways to build a contact form in React — plain useState, useReducer for more complex state, and react-hook-form for production use. All examples submit to an API endpoint and handle loading, success, and error states.

    Option 1: Simple useState Form

    The quickest approach. Good for forms with 2-4 fields:

    1import { useState } from "react";
    2
    3export function ContactForm() {
    4  const [name, setName] = useState("");
    5  const [email, setEmail] = useState("");
    6  const [message, setMessage] = useState("");
    7  const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
    8
    9  async function handleSubmit(e: React.FormEvent) {
    10    e.preventDefault();
    11    setStatus("submitting");
    12
    13    try {
    14      const res = await fetch("https://formboost.app/f/YOUR_ENDPOINT_ID", {
    15        method: "POST",
    16        headers: { "Content-Type": "application/json" },
    17        body: JSON.stringify({ name, email, message }),
    18      });
    19
    20      if (!res.ok) throw new Error();
    21      setStatus("success");
    22      setName(""); setEmail(""); setMessage("");
    23    } catch {
    24      setStatus("error");
    25    }
    26  }
    27
    28  if (status === "success") {
    29    return <p>Thanks! We'll be in touch soon.</p>;
    30  }
    31
    32  return (
    33    <form onSubmit={handleSubmit}>
    34      <div>
    35        <label htmlFor="name">Name</label>
    36        <input
    37          id="name"
    38          type="text"
    39          value={name}
    40          onChange={(e) => setName(e.target.value)}
    41          required
    42        />
    43      </div>
    44
    45      <div>
    46        <label htmlFor="email">Email</label>
    47        <input
    48          id="email"
    49          type="email"
    50          value={email}
    51          onChange={(e) => setEmail(e.target.value)}
    52          required
    53        />
    54      </div>
    55
    56      <div>
    57        <label htmlFor="message">Message</label>
    58        <textarea
    59          id="message"
    60          value={message}
    61          onChange={(e) => setMessage(e.target.value)}
    62          rows={5}
    63        />
    64      </div>
    65
    66      {status === "error" && <p>Something went wrong. Please try again.</p>}
    67
    68      <button type="submit" disabled={status === "submitting"}>
    69        {status === "submitting" ? "Sending..." : "Send Message"}
    70      </button>
    71    </form>
    72  );
    73}

    Option 2: react-hook-form (Recommended for Production)

    react-hook-form reduces boilerplate significantly and gives you validation, error messages, and submission state in one API:

    1npm install react-hook-form
    1import { useForm } from "react-hook-form";
    2
    3type FormValues = {
    4  name: string;
    5  email: string;
    6  message: string;
    7};
    8
    9export function ContactForm() {
    10  const {
    11    register,
    12    handleSubmit,
    13    reset,
    14    formState: { errors, isSubmitting, isSubmitSuccessful },
    15  } = useForm<FormValues>();
    16
    17  async function onSubmit(data: FormValues) {
    18    const res = await fetch("https://formboost.app/f/YOUR_ENDPOINT_ID", {
    19      method: "POST",
    20      headers: { "Content-Type": "application/json" },
    21      body: JSON.stringify(data),
    22    });
    23
    24    if (!res.ok) throw new Error("Submission failed");
    25    reset();
    26  }
    27
    28  if (isSubmitSuccessful) {
    29    return <p>Thanks! We'll get back to you soon.</p>;
    30  }
    31
    32  return (
    33    <form onSubmit={handleSubmit(onSubmit)}>
    34      <div>
    35        <label htmlFor="name">Name</label>
    36        <input
    37          id="name"
    38          type="text"
    39          {...register("name", { required: "Name is required" })}
    40        />
    41        {errors.name && <span>{errors.name.message}</span>}
    42      </div>
    43
    44      <div>
    45        <label htmlFor="email">Email</label>
    46        <input
    47          id="email"
    48          type="email"
    49          {...register("email", {
    50            required: "Email is required",
    51            pattern: {
    52              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    53              message: "Enter a valid email address",
    54            },
    55          })}
    56        />
    57        {errors.email && <span>{errors.email.message}</span>}
    58      </div>
    59
    60      <div>
    61        <label htmlFor="message">Message</label>
    62        <textarea
    63          id="message"
    64          rows={5}
    65          {...register("message", { required: "Message is required" })}
    66        />
    67        {errors.message && <span>{errors.message.message}</span>}
    68      </div>
    69
    70      <button type="submit" disabled={isSubmitting}>
    71        {isSubmitting ? "Sending..." : "Send Message"}
    72      </button>
    73    </form>
    74  );
    75}

    isSubmitting is automatically true while the async onSubmit is running — no manual state needed.

    Option 3: useReducer for Complex Forms

    When you have many fields or interdependent state, useReducer keeps things organized:

    1import { useReducer } from "react";
    2
    3type State = {
    4  fields: { name: string; email: string; message: string };
    5  status: "idle" | "submitting" | "success" | "error";
    6};
    7
    8type Action =
    9  | { type: "SET_FIELD"; field: keyof State["fields"]; value: string }
    10  | { type: "SET_STATUS"; status: State["status"] }
    11  | { type: "RESET" };
    12
    13const initialState: State = {
    14  fields: { name: "", email: "", message: "" },
    15  status: "idle",
    16};
    17
    18function reducer(state: State, action: Action): State {
    19  switch (action.type) {
    20    case "SET_FIELD":
    21      return { ...state, fields: { ...state.fields, [action.field]: action.value } };
    22    case "SET_STATUS":
    23      return { ...state, status: action.status };
    24    case "RESET":
    25      return initialState;
    26    default:
    27      return state;
    28  }
    29}
    30
    31export function ContactForm() {
    32  const [state, dispatch] = useReducer(reducer, initialState);
    33
    34  async function handleSubmit(e: React.FormEvent) {
    35    e.preventDefault();
    36    dispatch({ type: "SET_STATUS", status: "submitting" });
    37
    38    try {
    39      const res = await fetch("https://formboost.app/f/YOUR_ENDPOINT_ID", {
    40        method: "POST",
    41        headers: { "Content-Type": "application/json" },
    42        body: JSON.stringify(state.fields),
    43      });
    44
    45      if (!res.ok) throw new Error();
    46      dispatch({ type: "SET_STATUS", status: "success" });
    47    } catch {
    48      dispatch({ type: "SET_STATUS", status: "error" });
    49    }
    50  }
    51
    52  if (state.status === "success") return <p>Message sent!</p>;
    53
    54  return (
    55    <form onSubmit={handleSubmit}>
    56      {(["name", "email", "message"] as const).map((field) => (
    57        <div key={field}>
    58          <label htmlFor={field}>{field.charAt(0).toUpperCase() + field.slice(1)}</label>
    59          {field === "message" ? (
    60            <textarea
    61              id={field}
    62              value={state.fields[field]}
    63              onChange={(e) => dispatch({ type: "SET_FIELD", field, value: e.target.value })}
    64              rows={5}
    65            />
    66          ) : (
    67            <input
    68              id={field}
    69              type={field === "email" ? "email" : "text"}
    70              value={state.fields[field]}
    71              onChange={(e) => dispatch({ type: "SET_FIELD", field, value: e.target.value })}
    72              required
    73            />
    74          )}
    75        </div>
    76      ))}
    77
    78      {state.status === "error" && <p>Something went wrong.</p>}
    79
    80      <button type="submit" disabled={state.status === "submitting"}>
    81        {state.status === "submitting" ? "Sending..." : "Send"}
    82      </button>
    83    </form>
    84  );
    85}

    Handling the Response

    Formboost returns standard HTTP responses:

    StatusMeaning
    200 OKSubmission accepted
    422Missing required field
    429Rate limit hit
    5xxServer error

    Always check res.ok (covers 200–299) and handle the error case to give users feedback.

    Which Option Should You Use?

    Use caseRecommendation
    Simple 2-3 field formuseState
    Any production formreact-hook-form
    Many fields, complex validationreact-hook-form + Zod
    Forms with interdependent fieldsuseReducer

    For most projects, react-hook-form is the right choice — it handles edge cases (double-submit prevention, validation timing, error state) that plain useState doesn't.

    Setting Up Your Backend

    You need an endpoint to receive submissions. With Formboost:

    1. Sign up at dashboard.formboost.app
    2. Create a new endpoint — get your URL instantly
    3. Replace YOUR_ENDPOINT_ID in the examples above

    No server, no email configuration, no spam setup required. Formboost handles all of it.

    Get your free form endpoint →