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-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 |
|---|---|
| 200 OK | Submission accepted |
| 422 | Missing required field |
| 429 | Rate limit hit |
| 5xx | Server error |
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.