React Forms & Validation
Overview
Since React uses one-way data binding, you cannot directly update form inputs. React re-renders the input to reflect the current state value, so any keystroke that does not update state appears to be ignored. 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
"use client";
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)
"use client";
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.
Server Actions cannot be defined inline in a "use client" file. They must be in a separate file with "use server" at the top.
First, define the server action in a separate file:
// app/actions/userActions.ts
"use server";
export async function createUser(
previousState: string,
formData: FormData
): Promise<string> {
const name = formData.get("name") as string;
if (!name) return "Name is required";
// Save to database...
return `User ${name} created successfully!`;
}
Then import and use it in the client component:
// app/create-user/page.tsx
"use client";
import { useActionState } from "react";
import { createUser } from "@/app/actions/userActions";
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 optimistic value resolves to whatever the actual state becomes when the async action completes:
"use client";
import { useOptimistic, useState } from "react";
interface Todo {
id: number;
text: string;
}
// Assume saveTodoToServer is an async function that saves to the backend
async function saveTodoToServer(text: string): Promise<Todo> {
// API call to save the todo...
return { id: Date.now(), text };
}
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 LoginFormData {
email: string;
password: string;
}
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>();
const onSubmit = (data: LoginFormData) => {
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.
Self preparation QA
Be prepared to explain topics like these:
- What is a "controlled component" in React? Why does React require this pattern instead of letting you read input values directly from the DOM? — A controlled component has its value entirely driven by React state. Every keystroke triggers
onChange, which updates state, which triggers a re-render with the new value. This means React is the "single source of truth" for the input value at all times. Reading directly from the DOM would break React's one-way data flow principle and make state harder to track and debug. - How do Server Actions in React 19 change the traditional form handling pattern? What problem do they solve? — Traditional React forms require client-side state management (
useStatefor every field), anonSubmithandler, and a separate API endpoint. Server Actions let you pass anasyncfunction directly to the form'sactionprop — the function runs on the server, eliminating the need for a separate API route and reducing client-side JavaScript. This is especially useful for forms that primarily write data. - Why can you NOT define a Server Action inline in a
"use client"file? How must you structure the code instead? — Server Actions run on the server, while"use client"files are bundled for the browser. Mixing them in one file would create ambiguity about where code executes. Instead, define server actions in a separate file with"use server"at the top, then import them into client components. This enforces a clear boundary between server and client code. - What is the difference between
useActionStateanduseFormStatus? When would you use each? —useActionStatemanages the state returned by a server action (result message, pending flag) and is used in the component that defines the form.useFormStatusreads the pending state of the nearest parent<form>and must be used in a child component rendered inside the form. UseuseActionStatefor form-level state management; useuseFormStatusfor reusable submit buttons that work across different forms. - Why would you use React Hook Form instead of plain controlled components with
useState? What performance problem does it solve? — With plain controlled components, every keystroke triggerssetState, which re-renders the entire form including all fields. For large forms, this causes noticeable lag. React Hook Form uses uncontrolled components internally (registering inputs with refs), so individual field changes do not trigger full form re-renders. It also eliminates boilerplate for validation, error display, and submission handling. - What is Zod, and why pair it with React Hook Form? How does
z.infer<typeof schema>help with TypeScript? — Zod is a schema validation library that defines validation rules declaratively. Paired with React Hook Form viazodResolver, validation runs automatically on submit.z.infer<typeof schema>extracts the TypeScript type from the Zod schema, so you define the shape once and get both runtime validation and compile-time type checking — no need to keep a separate interface in sync. - What is "optimistic UI" (as enabled by
useOptimistic), and what trade-off does it introduce? — Optimistic UI immediately shows the expected result of an action (e.g., adding a todo to the list) before the server confirms it, making the app feel faster. The trade-off is complexity: if the server request fails, you must handle reverting the UI and showing an error. Without proper error handling, users may see data that was never actually saved.