Skip to main content

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 comparison

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>
);
}
Vue comparison

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>
);
}
danger

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.

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

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.