React Forms & Validation
Overview
Since React uses one-way data binding, you cannot directly update form inputs. React resets the value back to state on every render. Form fields must be bound to state, and state must be updated on input events.
Vue's v-model handles two-way binding automatically. In React, you manually wire value={state} + onChange={handler} for every input. This is called a controlled component.
React form components reference
Controlled components
A controlled component has its value driven by React state. On every keystroke, the onChange handler updates state, which triggers a re-render with the new value.
Simple text input
import { useState } from "react";
const SimpleForm = () => {
const [name, setName] = useState("");
return (
<div className="form-group">
<label htmlFor="nameInput">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
type="text"
className="form-control"
id="nameInput"
/>
<p>Current value: {name}</p>
</div>
);
};
Full form example
View component (presentation)
The form view receives values and a change handler via props:
interface FormValues {
input: string;
checkbox: boolean;
select: string;
radio1: boolean;
radio2: boolean;
textarea: string;
}
interface FormViewProps {
values: FormValues;
handleChange: (
target:
| (EventTarget & HTMLInputElement)
| (EventTarget & HTMLSelectElement)
| (EventTarget & HTMLTextAreaElement)
) => void;
}
const FormView = ({ values, handleChange }: FormViewProps) => {
return (
<form>
<div className="form-group">
<label htmlFor="exampleInputEmail1">Email</label>
<input
value={values.input}
onChange={(e) => handleChange(e.target)}
name="input"
type="text"
className="form-control"
id="exampleInputEmail1"
/>
</div>
<div className="form-group form-check">
<input
checked={values.checkbox}
onChange={(e) => handleChange(e.target)}
name="checkbox"
type="checkbox"
className="form-check-input"
id="exampleCheck1"
/>
<label className="form-check-label" htmlFor="exampleCheck1">
Check me out
</label>
</div>
<div className="form-group">
<label htmlFor="exampleFormControlSelect1">Example select</label>
<select
value={values.select}
onChange={(e) => handleChange(e.target)}
name="select"
className="form-control"
id="exampleFormControlSelect1"
>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
<div className="form-check form-check-inline">
<input
checked={values.radio1}
onChange={(e) => handleChange(e.target)}
name="radio"
value="radio1"
className="form-check-input"
type="radio"
id="inlineRadio1"
/>
<label className="form-check-label" htmlFor="inlineRadio1">
1
</label>
</div>
<div className="form-check form-check-inline">
<input
checked={values.radio2}
onChange={(e) => handleChange(e.target)}
name="radio"
value="radio2"
className="form-check-input"
type="radio"
id="inlineRadio2"
/>
<label className="form-check-label" htmlFor="inlineRadio2">
2
</label>
</div>
<div className="form-group">
<label htmlFor="exampleFormControlTextarea1">Example textarea</label>
<textarea
value={values.textarea}
onChange={(e) => handleChange(e.target)}
name="textarea"
className="form-control"
id="exampleFormControlTextarea1"
rows={3}
/>
</div>
</form>
);
};
Control component (state + logic)
import { useState } from "react";
import FormView from "./FormView";
const Form = () => {
const [values, setValues] = useState({
input: "foo",
checkbox: true,
select: "2",
radio1: false,
radio2: true,
textarea: "foo\nbar",
});
const handleChange = (
target:
| (EventTarget & HTMLInputElement)
| (EventTarget & HTMLSelectElement)
| (EventTarget & HTMLTextAreaElement)
) => {
if (target.type === "checkbox") {
setValues({
...values,
[target.name]: (target as HTMLInputElement).checked,
});
return;
}
if (target.type === "radio" && target.name === "radio") {
setValues({
...values,
radio1: target.value === "radio1",
radio2: target.value === "radio2",
});
return;
}
setValues({ ...values, [target.name]: target.value });
};
return <FormView values={values} handleChange={handleChange} />;
};
export default Form;
React 19 Form Features
React 19 introduces built-in form handling with Server Actions, reducing the need for manual state management in many cases.
Server Actions
A Server Action is an async function that runs on the server. Use it directly as a form's action prop:
// app/contact/page.tsx (Server Component)
async function submitContact(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
// Save to database, send email, etc.
console.log("Received:", name, email);
}
export default function ContactPage() {
return (
<form action={submitContact}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input name="name" type="text" className="form-control" id="name" required />
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input name="email" type="email" className="form-control" id="email" required />
</div>
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
);
}
Server Actions are unique to React/Next.js. Vue/Nuxt handles server-side form processing differently (API routes + fetch). Server Actions eliminate the need for a separate API endpoint.
useActionState
Handles form submission results and pending state. Returns the current state, a form action, and a pending flag:
"use client";
import { useActionState } from "react";
async function createUser(
previousState: string,
formData: FormData
): Promise<string> {
"use server";
const name = formData.get("name") as string;
if (!name) return "Name is required";
// Save to database...
return `User ${name} created successfully!`;
}
export default function CreateUserForm() {
const [message, formAction, isPending] = useActionState(createUser, "");
return (
<form action={formAction}>
<input name="name" type="text" className="form-control" />
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
{message && <p className="mt-2">{message}</p>}
</form>
);
}
useFormStatus
Reads the status of the parent <form>. Useful for creating reusable submit buttons:
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</button>
);
}
useFormStatus must be called from a component that is rendered inside a <form>. It will not work if called from the same component that renders the form.
useOptimistic
Show an optimistic UI while an async action is in progress. The value reverts if the action fails:
"use client";
import { useOptimistic, useState } from "react";
interface Todo {
id: number;
text: string;
}
export default function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
async function addTodo(formData: FormData) {
const text = formData.get("text") as string;
const optimisticTodo = { id: Date.now(), text };
addOptimisticTodo(optimisticTodo);
// Actual server call
const savedTodo = await saveTodoToServer(text);
setTodos((prev) => [...prev, savedTodo]);
}
return (
<>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
<form action={addTodo}>
<input name="text" type="text" />
<button type="submit">Add</button>
</form>
</>
);
}
Form Libraries
For complex forms with validation, form libraries reduce boilerplate significantly.
React Hook Form (recommended)
Performant, hook-based, with excellent TypeScript support:
"use client";
import { useForm } from "react-hook-form";
interface FormData {
email: string;
password: string;
}
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
className={`form-control ${errors.email ? "is-invalid" : ""}`}
{...register("email", {
required: "Email is required",
pattern: {
value: /^\S+@\S+$/i,
message: "Invalid email",
},
})}
/>
{errors.email && (
<div className="invalid-feedback">{errors.email.message}</div>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
className={`form-control ${errors.password ? "is-invalid" : ""}`}
{...register("password", {
required: "Password is required",
minLength: {
value: 6,
message: "Password must be at least 6 characters",
},
})}
/>
{errors.password && (
<div className="invalid-feedback">{errors.password.message}</div>
)}
</div>
<button type="submit" className="btn btn-primary mt-2">
Login
</button>
</form>
);
}
React Hook Form + Zod validation
Zod provides schema-based validation with automatic TypeScript type inference:
npm install zod @hookform/resolvers
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters"),
age: z.coerce.number().min(18, "Must be at least 18").max(120, "Invalid age"),
});
type FormData = z.infer<typeof schema>;
export default function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
console.log("Valid data:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
className={`form-control ${errors.name ? "is-invalid" : ""}`}
{...register("name")}
/>
{errors.name && (
<div className="invalid-feedback">{errors.name.message}</div>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
className={`form-control ${errors.email ? "is-invalid" : ""}`}
{...register("email")}
/>
{errors.email && (
<div className="invalid-feedback">{errors.email.message}</div>
)}
</div>
<div className="form-group">
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
className={`form-control ${errors.age ? "is-invalid" : ""}`}
{...register("age")}
/>
{errors.age && (
<div className="invalid-feedback">{errors.age.message}</div>
)}
</div>
<button type="submit" className="btn btn-primary mt-2">
Register
</button>
</form>
);
}
Other form libraries
- Formik -- declarative approach with higher-level abstractions
- TanStack Form -- framework-agnostic, headless
- React JSON Schema Form -- auto-generates forms from JSON Schema
React Hook Form offers better performance and a simpler API. Formik provides a more declarative approach. For new projects, React Hook Form + Zod is the most popular combination.