Hand-Built React Forms: Controlled Inputs, Validation and Submit States
Learn how React forms work from controlled inputs and per-type handlers to validation, submission states, dynamic fields, uploads and when a form library pays off.
This article is published in English.
Sources: React Forms: A Complete Guide from Basics to Production Patterns
Sign-in, checkout, search, profile settings, surveys: nearly every app is built around forms. In React, a form means managing values, validation, error messages, submission, loading and server errors, fields added at runtime, resets and performance. This guide builds that picture step by step, from the controlled input to a complete registration form without a library, and shows when React Hook Form and Zod become worth adding.
How a form moves through a React app
A form collects information from the user, as in this registration screen:
┌──────────────────────────────┐
│ Create Account │
│ │
│ Name [______________] │
│ Email [______________] │
│ Password [______________] │
│ │
│ [ Register ] │
└──────────────────────────────┘
React connects those inputs to application logic, and almost every form follows the same lifecycle:
User Input
↓
Form State
↓
Validation
↓
Submit
↓
API Request
↓
Success / Error
Controlled components: state as the source of truth
In a controlled component, the input's value comes from React state and every change is written back through onChange.
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
);
}
Setting value={email} means React decides what the field displays. Each keystroke travels through this loop:
User types
↓
onChange fires
↓
setEmail()
↓
React state updates
↓
Component renders
↓
Input receives new value
Because the displayed value is always derived from state, state is the single source of truth.
The name "controlled" reflects that the input does not own its value:
<input
value={email}
onChange={handleChange}
/>
Data circles from state to the element, through the user's typing and the handler, and back into state:
React State
│
▼
input value
│
▼
User types
│
▼
onChange
│
▼
React State
One state object for many fields
A useState call per field scales poorly:
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
A single object keyed by field name is easier to maintain. This form keeps three fields in one object, shares one handleChange and logs the data on submit.
import { useState } from "react";
function RegisterForm() {
const [form, setForm] = useState({
name: "",
email: "",
password: "",
});
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={form.name}
onChange={handleChange}
/>
<input
name="email"
value={form.email}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
<button type="submit">
Register
</button>
</form>
);
}
The key line is the computed property [name]: value. Each input's name attribute matches a state key, so one handler updates whichever field fired.
For text inputs, the handler reads the element from the event:
<input
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
event.target is the input, so event.target.value is its current text:
User types: Salim
event.target.value
↓
"Salim"
Handling each input type correctly
Forms mix text, password, number, checkbox, radio, select, textarea, file, date and range controls. Most follow the same pattern, with a few important differences.
Text, password and number
A text field binds value to its key:
<input
type="text"
value={form.name}
onChange={handleChange}
/>
A password field differs only in type:
<input
type="password"
value={form.password}
onChange={handleChange}
/>
So does a number field:
<input
type="number"
value={form.age}
onChange={handleChange}
/>
The catch: event.target.value is a string even with type="number". The snippet below mixes code and explanation, but the point is that you get "25" rather than 25 and need Number() to convert.
const age = event.target.value;
may produce:
"25" rather than: 25
If you need a number:
const age = Number(event.target.value);
Watch empty values: Number("") returns 0, so many forms keep the raw string and convert only on validation or submit.
Checkboxes use checked
For a checkbox, bind checked to a boolean and read event.target.checked:
<input
type="checkbox"
checked={form.agree}
onChange={(event) => {
setForm((previous) => ({
...previous,
agree: event.target.checked,
}));
}}
/>
event.target.value on a checkbox returns its value attribute (by default "on"), not whether it is ticked.
Select, textarea and radio groups
A select is controlled through value on the select element; an empty first option works as a placeholder.
<select
name="role"
value={form.role}
onChange={handleChange}
>
<option value="">
Select role
</option>
<option value="frontend">
Frontend Developer
</option>
<option value="backend">
Backend Developer
</option>
</select>
React treats textarea like an input, with a value prop instead of child text:
<textarea
name="message"
value={form.message}
onChange={handleChange}
/>
Radio buttons in a group share a name. Each is checked when its value matches state, and the shared handler stores the choice in form.gender.
<label>
<input
type="radio"
name="gender"
value="male"
checked={form.gender === "male"}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={form.gender === "female"}
onChange={handleChange}
/>
Female
</label>
Taking over submission
By default a browser submits the form itself and may navigate or reload. React apps handle submission in JavaScript, so the submit handler begins with event.preventDefault(), as in the registration example. Then the flow is yours:
Submit
↓
preventDefault()
↓
Validate
↓
API
↓
Update UI
Validating before the request
Validation checks that input is acceptable. A first version collects errors keyed by field:
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
}
if (!form.password) {
errors.password = "Password is required";
}
return errors;
};
The submit handler validates first, stores errors and exits early when any exist:
const handleSubmit = (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Submit form
};
Errors live in their own state:
const [errors, setErrors] = useState({});
At a given moment it might contain:
{
email: "Invalid email",
password: "Password must contain at least 8 characters"
}
Each field renders its message conditionally:
{errors.email && (
<p>{errors.email}</p>
)}
A better validate takes the form as an argument, making it pure and testable, and checks format and length once a field is present:
function validate(form) {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
} else if (!form.email.includes("@")) {
errors.email = "Invalid email";
}
if (!form.password) {
errors.password = "Password is required";
} else if (form.password.length < 8) {
errors.password =
"Password must contain at least 8 characters";
}
return errors;
}
The handler calls it the same way:
const errors = validate(form);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
The includes("@") test only catches obvious typos; the real proof that an address works is a confirmation email.
Client and server validation do different jobs
Client-side validation runs in the browser and typically covers:
- Required fields
- Email format
- Minimum password length
- Password confirmation
It gives fast feedback, a better experience and fewer wasted requests, but it is not a security boundary. The server must repeat the checks and answer questions the browser cannot:
- Is the email already registered?
- Is the user authorized?
- Is the price valid?
- May this user perform this operation?
Secure applications use both. Never write logic like this:
if (clientValidationPassed) {
// Data is safe
}
Browser checks can be bypassed by editing the page or calling the API directly, so the backend validates every request independently:
Request
↓
Authentication
↓
Authorization
↓
Validation
↓
Business Logic
↓
Database
Modeling submission states
A production form moves through distinct states the UI should reflect:
Idle
↓
Submitting
↓
Success
or
Idle
↓
Submitting
↓
Error
Two pieces of state track them:
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");
The async handler raises the flag, clears old server errors, awaits the request, records a friendly message on failure, and resets the flag in finally either way.
const handleSubmit = async (event) => {
event.preventDefault();
setIsSubmitting(true);
setServerError("");
try {
await createUser(form);
} catch (error) {
setServerError("Unable to create account");
} finally {
setIsSubmitting(false);
}
};
Disabling and relabeling the button during the request prevents duplicate submissions:
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
The button's lifecycle mirrors the request:
User clicks Submit
↓
Request starts
↓
Button disabled
↓
Request finishes
↓
Button enabled
Controlled versus uncontrolled inputs
With a controlled input, React state holds the value. The block below shows the short form, a full component and the data flow, separated by lines of asterisks.
<input
value={email}
onChange={handleChange}
/>
********************************************
function Form() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
);
}
********************************************
DOM event
↓
React state
↓
Render
↓
DOM value
With an uncontrolled input, the DOM keeps the value and React reads it through a ref, typically on submit, without storing each keystroke:
const emailRef = useRef();
<input ref={emailRef} />
********************************************
function Form() {
const emailRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
console.log(emailRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} />
<button type="submit">
Submit
</button>
</form>
);
}
********************************************
DOM
↓
Input value
Choose controlled inputs for immediate validation, conditional UI, derived values, formatting, React-driven state or custom interactions. Because React knows the value, the UI can react instantly:
{password.length < 8 && (
<p>Password is too short</p>
)}
Choose uncontrolled inputs for simple forms, when per-keystroke state adds nothing, when you want fewer updates, or when a library manages refs for you. React 19 form actions also build on this model; see async forms with useActionState and useOptimistic.
When a form library earns its place
React Hook Form
Wiring dozens of fields by hand is tedious. React Hook Form's register connects an input and declares its rules, handleSubmit validates before calling your callback, and formState.errors exposes messages.
import { useForm } from "react-hook-form";
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "Email is required",
})}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message:
"Password must be at least 8 characters",
},
})}
/>
{errors.password && (
<p>{errors.password.message}</p>
)}
<button type="submit">
Login
</button>
</form>
);
}
It simplifies registration, validation, errors, submission, resets, dynamic fields and form state. Because it registers inputs uncontrolled by default, it avoids re-rendering the whole form per keystroke as a naive controlled form does.
Schema validation with Zod
As rules multiply, scattered if statements drift apart. Zod describes the expected data shape in one declaration:
import { z } from "zod";
const userSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.email("Invalid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
});
A common production pairing uses the library for state and the schema for rules:
React Hook Form
+
Zod
↓
Form state + validation
The schema can be this compact:
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
React Hook Form plugs a schema in through a resolver, which suits large forms whose rules must stay consistent.
Dynamic, conditional and dependent fields
Repeating groups
Users often add entries on demand, such as education records:
Education
Degree [________]
College [________]
[ + Add Education ]
Store them as an array of objects:
const [education, setEducation] = useState([
{
degree: "",
college: "",
},
]);
Adding a row appends an empty object:
setEducation((previous) => [
...previous,
{
degree: "",
college: "",
},
]);
Rendering maps over the array:
{education.map((item, index) => (
<div key={index}>
<input
value={item.degree}
onChange={(event) => {
// update degree
}}
/>
<input
value={item.college}
onChange={(event) => {
// update college
}}
/>
</div>
))}
An index key is only safe while rows are never removed or reordered; otherwise use stable ids, or typed text can attach to the wrong row.
Conditional fields
Some fields depend on an earlier choice, such as company details for a Business account:
Account Type
○ Individual
○ Business
If the user chooses:
Business
show:
Company Name
GST Number
Company Address
A conditional expression handles it:
{form.accountType === "business" && (
<>
<input
name="companyName"
value={form.companyName}
onChange={handleChange}
/>
<input
name="gstNumber"
value={form.gstNumber}
onChange={handleChange}
/>
</>
)}
If the user switches back, clear or exclude the hidden values so stale data is not submitted.
Cascading selections
Country, state and city form a chain:
Country
↓
State
↓
City
A change upstream resets everything below it before fetching new options:
Country changes
↓
Clear State
↓
Fetch states
↓
User selects State
↓
Fetch cities
Since the options come from the server, TanStack Query pairs well with the form library for caching and loading states.
File uploads
File inputs cannot be controlled; read the file from event.target.files:
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
console.log(file);
}
}}
/>
To upload it, send a FormData body:
const formData = new FormData();
formData.append("name", form.name);
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData,
});
Do not set Content-Type: multipart/form-data yourself with fetch and FormData; the browser generates that header with the required boundary.
Keeping large forms fast
Large, complex forms strain a naive controlled form that re-renders on every keystroke:
User types
↓
setState
↓
Render
↓
Large form tree
Useful countermeasures:
- Keep state local rather than global without reason.
- Split the form into sections so each change re-renders less:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
- Use a suitable library such as React Hook Form.
- Avoid heavy calculations or API calls per character.
- Debounce what must run, such as a username availability check:
Username input
↓
Wait 300ms
↓
Check availability
A complete form without a library
The final example combines an initialForm constant for resets, separate state for field errors, submission and server errors, a validate function, and an async submit that posts JSON, treats a non-OK response as failure, resets on success and always clears the loading flag. Labels link to inputs via htmlFor and id.
import { useState } from "react";
const initialForm = {
name: "",
email: "",
password: "",
};
function RegisterForm() {
const [form, setForm] = useState(initialForm);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [serverError, setServerError] =
useState("");
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.includes("@")) {
errors.email = "Enter a valid email";
}
if (form.password.length < 8) {
errors.password =
"Password must be at least 8 characters";
}
return errors;
};
const handleSubmit = async (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setServerError("");
setIsSubmitting(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error("Registration failed");
}
setForm(initialForm);
} catch (error) {
setServerError(
"Unable to create your account."
);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
/>
{errors.name && (
<p>{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>
{errors.email && (
<p>{errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<p>{errors.password}</p>
)}
</div>
{serverError && (
<p>{serverError}</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
</form>
);
}
export default RegisterForm;
For production, also set aria-invalid on invalid fields and link messages with aria-describedby.
The whole flow at a glance
Everything above fits one decision tree, from input through validation to field errors or an API call with success and error branches:
React Form
│
▼
User Input
│
▼
Form State
│
▼
Validation
/ \
Valid Invalid
│ │
▼ ▼
Submit Show Errors
│
▼
API Call
/ \
Success Error
│ │
▼ ▼
Reset/Form Show Server
Success UI Error
Key takeaways
- Controlled inputs make state the source of truth; one object plus a
[name]: valuehandler covers most fields. - Numbers arrive as strings, checkboxes use
checked, selects are controlled on theselectelement, and file inputs are always uncontrolled. - Client validation serves the user; only server validation protects data.
- Model submission states explicitly and disable submit during requests.
- Add React Hook Form and Zod when field count, shared rules or render cost make hand-written state painful.