React Contact Form Example with Validation
Build a React contact form from scratch with useState, validation, loading states, and error handling. Works with any form backend including Formboost.
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.
Key takeaways
useStatefor field values, oneasyncsubmit handler, and a status flag covers the whole form.- Posting JSON returns
202 Accepted; checkres.okrather than parsing the body.- Controlled inputs make validation and the reset-after-success step trivial.
- The same component works in Next.js, Gatsby, Vite or Create React App — nothing here is framework-specific.
A controlled component is a React input whose value is driven by state rather than the DOM, which is what lets you validate, disable and reset the form from one place.
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-form1import { 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:
| Status | Meaning |
|---|---|
| 202 Accepted | Submission accepted; it is processed in the background |
| 400 | Empty submission, or a body that is not valid JSON |
| 413 | Payload too large (file uploads are not accepted) |
| 415 | Content-Type is not JSON or form-encoded |
| 429 | Rate limit exceeded — wait for RateLimit-Reset |
| 5xx | Server error |
Error bodies carry success: false, a message, and a machine-readable name
(EMPTY_SUBMISSION, RATE_LIMITED, …) to branch on. Required fields are not validated at the
endpoint, so validate them in your own form before posting.
Always check res.ok (covers 200–299) and handle the error case to give users feedback.
Which Option Should You Use?
| Use case | Recommendation |
|---|---|
| Simple 2-3 field form | useState |
| Any production form | react-hook-form |
| Many fields, complex validation | react-hook-form + Zod |
| Forms with interdependent fields | useReducer |
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:
- Sign up at dashboard.formboost.app
- Create a new endpoint — get your URL instantly
- Replace
YOUR_ENDPOINT_IDin the examples above
No server, no email configuration, no spam setup required. Formboost handles all of it.