A React form can stay simple for longer than you might expect.
One input usually means one state value and one change handler. Add another field and the same approach still feels fine. Then validation arrives, error messages appear, the submit button needs its own rules, and suddenly the component has twice as much code as the form itself.
It’s tempting to reach for a form library immediately.
For a large checkout flow or a form with dozens of fields, that may be the right decision. For a login screen, settings panel, newsletter form, or small registration flow, plain React is often enough.
The trick is deciding what actually belongs in state.
We’ll start with a single input and gradually turn it into a complete form with shared handlers, validation, derived state, and a small reusable container.
Controlled and Uncontrolled Inputs
Consider a comment box.
We only care about the text when somebody presses Submit. Nothing else on the page depends on every character being typed.
A ref works well here:
import { useRef } from "react";
function CommentBox() {
const inputRef = useRef(null);
function handleSubmit() {
const comment = inputRef.current?.value.trim();
if (!comment) return;
console.log(comment);
}
return (
<div>
<textarea
ref={inputRef}
placeholder="Write a comment..."
/>
<button onClick={handleSubmit}>
Submit
</button>
</div>
);
}The browser owns the textarea value. React simply reads it when necessary.
Typing doesn’t update React state, which is perfectly fine because the component doesn’t need that value while the user is typing.
Now take a search input:
import { useState } from "react";
function SearchInput() {
const [query, setQuery] = useState("");
return (
<input
type="search"
value={query}
onChange={event => {
setQuery(event.target.value);
}}
placeholder="Search..."
/>
);
}This time React owns the current value.
Every change follows the same small loop:
User types
↓
onChange
↓
setQuery()
↓
render
↓
value={query}That makes the input controlled.
The distinction matters because controlled inputs give the rest of your component immediate access to their current values.
If the UI needs to validate, filter, calculate, show something conditionally, or enable another control while the user types, state is usually the more convenient place for that value.
A Ref Does Not Automatically Mean Uncontrolled
This is worth clearing up because the terminology is occasionally explained badly.
Using useRef() somewhere in a component does not make an input uncontrolled.
This is controlled:
<input
ref={inputRef}
value={username}
onChange={handleChange}
/>React still determines the value through value={username}.
This is uncontrolled:
<input ref={inputRef} />Here the DOM owns the current value.
The question isn’t whether a ref exists.
The question is who controls value.
Two Fields Are Where Things Get Interesting
Let’s build a login form.
The obvious first version uses separate state:
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");Then two handlers:
function handleUsernameChange(event) {
setUsername(event.target.value);
}
function handlePasswordChange(event) {
setPassword(event.target.value);
}There’s nothing wrong with this.
For exactly two fields, you could leave it alone.
The problem appears when the form grows:
username
password
email
firstName
lastName
company
country
phoneCreating a setter and nearly identical handler for every field becomes tedious.
I’d rather group related form values:
const [form, setForm] = useState({
username: "",
password: ""
});Now the state looks like the form itself.
The next question is how to update individual properties without creating a handler for each one.
Let the Input Tell the Handler What Changed
HTML already gives us a useful mechanism for this: name.
<input
name="username"
value={form.username}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>Both fields can use the same function:
function handleChange(event) {
const { name, value } = event.target;
setForm(prev => ({
...prev,
[name]: value
}));
}When the username changes:
name === "username";so:
[name]: valuebecomes:
username: valueFor the password input, the exact same function updates password.
Adding another field doesn’t require another handler:
const [form, setForm] = useState({
username: "",
email: "",
password: ""
});Then:
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>That’s it.
The relationship between the name attribute and the state property does the routing for us.
Don’t Accidentally Delete the Rest of the Form
There’s one important part of that update:
setForm(prev => ({
...prev,
[name]: value
}));Specifically:
...prevWithout it:
setForm({
[name]: value
});we replace the entire object.
Imagine this state:
{
username: "alex",
password: ""
}Then the password changes to "s".
Without spreading the previous values, the new state becomes:
{
password: "s"
}The username disappeared.
We don’t want a new one-property form object. We want the old object with one property changed.
setForm(prev => ({
...prev,
[name]: value
}));This pattern will show up again when we add errors.
Keep Errors Away From Form Values
Now the form needs validation.
One option is putting everything into one state object:
{
username: "",
usernameError: "",
password: "",
passwordError: ""
}I prefer not to do that.
Values and errors represent different things, so I keep them separate:
const [form, setForm] = useState({
username: "",
password: ""
});
const [errors, setErrors] = useState({});form contains what the user entered.
errors contains what’s wrong with it.
That gives us a simple shape such as:
form = {
username: "al",
password: "secret"
};
errors = {
username: "Username must be at least 3 characters",
password: ""
};Now we need a validator.
Start With a Small Validator
For two fields, a straightforward function is easier to understand than building a generic validation system.
function validateField(name, value) {
let message = "";
if (name === "username") {
const username = value.trim();
if (!username) {
message = "Username is required";
} else if (username.length < 3) {
message =
"Username must be at least 3 characters";
}
}
if (name === "password") {
if (!value) {
message = "Password is required";
} else if (value.length < 6) {
message =
"Password must be at least 6 characters";
}
}
setErrors(prev => ({
...prev,
[name]: message
}));
}An empty string means that field currently has no validation error.
We can run it from the existing change handler:
function handleChange(event) {
const { name, value } = event.target;
setForm(prev => ({
...prev,
[name]: value
}));
validateField(name, value);
}One user action now updates both sides of the form:
input changes
↓
handleChange()
↙ ↘
form validation
↓
errorsThere is no second set of input handlers just for validation.
Validate the Value You Already Have
Here’s a subtle mistake that’s easy to make:
function handleChange(event) {
const { name, value } = event.target;
setForm(prev => ({
...prev,
[name]: value
}));
validateField(name, form[name]);
}At first glance, that looks reasonable.
We update the form, then validate the form.
But setForm() does not immediately replace form inside the currently executing function.
form still refers to the state from the current render.
Fortunately, we already have the latest value:
valueSo use it:
validateField(name, value);There’s no reason to update state and immediately try to read the same information back from it.
The event already gave us what we need.
Functional Updates Keep Object State Predictable
We’ve used this several times:
setForm(prev => ({
...prev,
[name]: value
}));and:
setErrors(prev => ({
...prev,
[name]: message
}));Why not just write:
setErrors({
...errors,
[name]: message
});For many simple interactions, that will appear to work.
The functional version is safer when the new state depends on the previous state.
Suppose the error object currently contains:
{
username: "Too short"
}and another update adds a password error.
We want:
{
username: "Too short",
password: "Password is required"
}The updater function receives the latest state available to that queued update:
setErrors(prev => ({
...prev,
password: "Password is required"
}));It’s a small habit that becomes useful as a component starts doing more than one state update.
isValid Probably Doesn’t Need State
At this stage we have values and errors.
We also need to decide whether submission is allowed.
It would be easy to add:
const [isValid, setIsValid] = useState(false);But then we have another value to maintain.
Every time form changes, maybe isValid changes.
Every time errors changes, maybe it changes again.
That gives us three pieces of state that can disagree with each other.
There’s an easier option.
Calculate it:
const isValid =
form.username.trim().length >= 3 &&
form.password.length >= 6 &&
!errors.username &&
!errors.password;isValid is not new information.
It’s an answer derived from information we already have.
On every render, React evaluates it again using the current form and errors.
No setter is required.
I try to keep this distinction in mind whenever I add state:
Is this new information, or can I calculate it from state I already have?
If it’s cheap to calculate, I usually don’t store a second copy.
Error Messages Can Be Just as Simple
With errors in state, rendering them doesn’t require any special logic:
{errors.username && (
<span className="error">
{errors.username}
</span>
)}If errors.username is empty, nothing appears.
If validation sets:
errors.username =
"Username must be at least 3 characters";React renders the message.
The submit button follows the same idea:
<button
type="submit"
disabled={!isValid}
>
Sign in
</button>We aren’t calling functions like:
showUsernameError();
disableSubmitButton();The UI is simply a result of the current data.
That relationship is what makes controlled forms useful.
A Small FormContainer
The JSX for each field is beginning to repeat:
label
input
errorI don’t want to build a complete field abstraction yet, but extracting the outer form structure can make the final component cleaner.
A small FormContainer is enough:
function FormContainer({
title,
onSubmit,
children
}) {
return (
<section className="form-container">
<h2>{title}</h2>
<form onSubmit={onSubmit}>
{children}
</form>
</section>
);
}This component doesn’t know anything about username, passwords, validation, or state.
It only owns the common layout.
That distinction is useful.
I wouldn’t put form logic into a component just because its name contains Form.
The login component should own the login behavior. The container should remain boring.
Putting the Form Together
Now we can assemble everything.
import { useState } from "react";
function FormContainer({
title,
onSubmit,
children
}) {
return (
<section className="form-container">
<h2>{title}</h2>
<form onSubmit={onSubmit}>
{children}
</form>
</section>
);
}
export default function LoginForm() {
const [form, setForm] = useState({
username: "",
password: ""
});
const [errors, setErrors] = useState({});
function validateField(name, value) {
let message = "";
if (name === "username") {
const username = value.trim();
if (!username) {
message = "Username is required";
} else if (username.length < 3) {
message =
"Username must be at least 3 characters";
}
}
if (name === "password") {
if (!value) {
message = "Password is required";
} else if (value.length < 6) {
message =
"Password must be at least 6 characters";
}
}
setErrors(prev => ({
...prev,
[name]: message
}));
}
function handleChange(event) {
const { name, value } = event.target;
setForm(prev => ({
...prev,
[name]: value
}));
validateField(name, value);
}
const isValid =
form.username.trim().length >= 3 &&
form.password.length >= 6 &&
!errors.username &&
!errors.password;
function handleSubmit(event) {
event.preventDefault();
if (!isValid) return;
console.log("Submitting:", form);
}
return (
<FormContainer
title="Sign in"
onSubmit={handleSubmit}
>
<div className="form-item">
<label htmlFor="username">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
value={form.username}
onChange={handleChange}
/>
{errors.username && (
<span className="error">
{errors.username}
</span>
)}
</div>
<div className="form-item">
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<span className="error">
{errors.password}
</span>
)}
</div>
<button
type="submit"
disabled={!isValid}
>
Sign in
</button>
</FormContainer>
);
}There are only two actual state values:
form
errorsisValid is calculated.
Both fields share handleChange().
Validation receives the value directly instead of waiting for a state update.
FormContainer handles layout but stays completely outside the state logic.
For a small form, that’s already a fairly comfortable structure.
Follow One Keystroke Through the Component
It’s useful to trace what happens when someone actually types.
Suppose the form starts here:
form = {
username: "",
password: ""
};
errors = {};The user enters:
ainto the username field.
The browser calls handleChange() with:
name = "username";
value = "a";This update is queued:
setForm(prev => ({
...prev,
username: "a"
}));Then:
validateField("username", "a");finds that the value is too short.
The errors become:
{
username:
"Username must be at least 3 characters"
}React renders again.
The input displays a.
The error appears underneath it.
The validity expression is also evaluated:
const isValid =
false &&
false &&
false;So the button remains disabled.
The user continues typing:
alexValidation now produces an empty error:
{
username: ""
}The next render removes the message.
There isn’t a separate command telling React to hide it.
The data no longer contains an error, so the JSX no longer produces the error element.
Then the Password Changes
Suppose we have:
form = {
username: "alex",
password: ""
};The user types:
secret1The next form state becomes:
{
username: "alex",
password: "secret1"
}and the errors are:
{
username: "",
password: ""
}Now:
isValid === true;The button becomes enabled during that render.
This is the main reason I wouldn’t store isValid separately.
If we did, we’d have to keep another state value synchronized with values that already tell us the answer.
Validation Doesn’t Have to Run on Every Character
The example above validates in onChange because it makes the data flow easy to see.
That doesn’t mean every form should behave that way.
Showing an error after the first character can feel unnecessarily aggressive.
For some forms, onBlur works better:
function handleBlur(event) {
const { name, value } = event.target;
validateField(name, value);
}Then:
<input
name="username"
value={form.username}
onChange={handleChange}
onBlur={handleBlur}
/>The user can type normally, and validation appears after leaving the field.
Another option is validating only when Submit is pressed.
Or you can combine the approaches: validate after the first blur, then validate subsequent changes immediately.
That’s a UX decision rather than a state-management rule.
The underlying structure can stay the same.
Don’t Trust disabled as Validation
Our button has:
disabled={!isValid}That’s useful for the interface.
It should not be the only thing preventing invalid submission.
We still check inside the handler:
function handleSubmit(event) {
event.preventDefault();
if (!isValid) return;
console.log("Submitting:", form);
}And in a real application, the server needs to validate the submitted values again.
Client-side validation improves the experience.
It isn’t a security boundary.
When the Form Gets Bigger
This pattern scales comfortably for a while.
Adding email is straightforward:
const [form, setForm] = useState({
username: "",
email: "",
password: ""
});The existing handler already knows how to update it:
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>Then add the email rules to validateField().
Eventually, though, that validator can become the ugly part of the component.
If it turns into dozens of conditions, I’d move the rules outside:
const validators = {
username(value) {
const username = value.trim();
if (!username) {
return "Username is required";
}
if (username.length < 3) {
return "Username must be at least 3 characters";
}
return "";
},
password(value) {
if (!value) {
return "Password is required";
}
if (value.length < 6) {
return "Password must be at least 6 characters";
}
return "";
}
};Then:
function validateField(name, value) {
const validate = validators[name];
const message = validate
? validate(value)
: "";
setErrors(prev => ({
...prev,
[name]: message
}));
}I wouldn’t start here for two fields.
Once the conditions become annoying to navigate, the object earns its place.
When I’d Reach for a Form Library
Plain React isn’t a competition with form libraries.
At some point the form may contain dynamic field arrays, deeply nested values, schema validation, asynchronous checks, conditional sections, multi-step flows, server errors, touched state, dirty state, and dozens of inputs.
You can build all of that yourself.
The more useful question is whether you still want to.
For a login form, I’d rather see ten lines of obvious React than an abstraction that requires learning another API.
For a large checkout or admin editor, a form library can remove a lot of repetitive infrastructure.
Understanding the plain version still pays off because the same questions remain underneath the library:
Where are the values?
When does validation happen?
How are errors represented?
Which values are derived?
What happens on submit?A library gives you different tools for answering them. It doesn’t make those decisions disappear.
The Whole Flow
Our final form can be reduced to this:
User input
↓
handleChange
↙ ↘
setForm validateField
↓
setErrors
↘ ↙
render
↙ ↓ ↘
values errors isValid
↓
buttonSubmission is even smaller:
Submit
↓
preventDefault
↓
isValid?
↙ ↘
no yes
↓ ↓
stop submitThat is most of the architecture.
The interesting part isn’t any single hook. It’s keeping each value responsible for one thing.
Final Thoughts
Small React forms don’t need much machinery.
Use an uncontrolled input when React doesn’t need to react to every keystroke. Move the value into state when the rest of the interface depends on it.
Once several related fields appear, an object and a shared name-based handler can remove a surprising amount of repetition.
Keep validation results separate from the user’s data. Pass fresh input values directly to validation instead of expecting state to update synchronously. Use functional updates when you’re building new object state from the previous object.
Most importantly, don’t store every useful value in state.
For the form we built, the core data is:
form
errorswhile:
isValidis simply something we can calculate.
That small distinction keeps the form easier to follow, and it becomes more valuable with every field you add.
