Forms are the first place where a React beginner feels the ground move. Everything else you have built so far, a counter, a to-do list, a toggle, is forgiving. A form is not. It has a dozen small moving parts: what the user types, what you remember, what you show back, and what you finally send to a server.
The good news is that React only asks you to learn one idea to make sense of all of it. Once that idea clicks, forms stop feeling like a puzzle and start feeling like any other piece of state.
In this article we will build a small sign-up form from scratch, one step at a time. It checks the input as the user types, shows friendly error messages only when they make sense, and resets itself after a successful submit. I tested the final code in a real browser, so you can copy it with confidence.
What We Are Building
A sign-up form with three fields: name, email and password. Here is how it should behave:
- Nothing shouts at the user while they are still typing their first letter.
- When they leave a field with something wrong in it, a short, kind message appears.
- If they hit Sign up with mistakes, every problem is shown at once.
- If everything is fine, we "send" the data, show a welcome message and clear the form.
No libraries, no tricks. Just React and a few lines of plain JavaScript.
Set Up the Project
If you do not have a React project yet, Vite is the fastest way to get one. Open your terminal and run:
npm create vite@latest react-form-poc -- --template react
cd react-form-poc
npm install
npm run dev
Open the address Vite prints (usually http://localhost:5173) and you will see the starter page. We will create one new file, src/SignupForm.jsx, and use it in App.jsx later.
The One Idea You Need: Controlled Inputs
In plain HTML, an input remembers its own value. In React, we usually take that job away from the input and give it to state. This is called a controlled input, and it sounds fancier than it is. It means two things:
- The input always displays whatever is in state (the
value prop).
- Every keystroke updates that state (the
onChange handler).
Here is the smallest possible example:
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />
That loop, state to screen and keystroke back to state, is the whole foundation. Because React always knows what is in the field, validating, clearing or pre-filling it later becomes easy.
Step 1: Keep All Fields in One Object
Three fields could mean three useState calls, but that gets noisy fast. A single object scales better:
import { useState } from "react";
const initialValues = { name: "", email: "", password: "" };
export default function SignupForm() {
const [values, setValues] = useState(initialValues);
function handleChange(e) {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
}
// ...
}
Notice the trick: one handleChange serves every input. It works because each input has a name attribute matching a key in our object. Type in the email field and e.target.name is "email", so only that key gets updated. The [name]: value part is just JavaScript's way of using a variable as a key.
Step 2: Write a Validation Function
Keep the rules outside the component, in a plain function that takes the values and returns an object of error messages:
export function validate(values) {
const errors = {};
if (!values.name.trim()) errors.name = "Please tell us your name.";
if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "That email doesn't look right.";
if (values.password.length < 8) errors.password = "Use at least 8 characters.";
return errors;
}
A plain function is easy to read, easy to test, and easy to change when your boss says "make the password rule stricter." If a field is fine, it simply does not appear in the result.
Then, inside the component, we call it on every render:
const errors = validate(values);
const isValid = Object.keys(errors).length === 0;
You might wonder why we do not store errors in state. We do not need to. Errors are fully decided by values, so we can calculate them fresh each time. Fewer pieces of state means fewer chances for them to disagree with each other.
Step 3: Be Kind, Show Errors Only After "Touched"
If we showed every error immediately, the user would see "That email doesn't look right" before typing a single character. Nobody likes being scolded that early. So we track which fields the user has already visited:
const [touched, setTouched] = useState({});
function handleBlur(e) {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
}
const showError = (field) => touched[field] && errors[field];
onBlur fires when the user leaves a field. From that moment on, that field is "touched" and allowed to display its error. It is a tiny detail, but it is the difference between a form that feels hostile and one that feels helpful.
Step 4: Handle the Submit
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e) {
e.preventDefault();
setTouched({ name: true, email: true, password: true });
if (!isValid) return;
console.log("Sending to server:", values);
setSubmitted(true);
setValues(initialValues);
setTouched({});
}
Three things are happening here, and each one matters:
e.preventDefault() stops the browser from reloading the page, which is what forms do by default.
- Marking every field as touched makes all the errors visible at once if the user clicked Sign up too early.
- If everything is valid, we send the data (here we just log it, but this is where your
fetch or Axios call goes), then reset the form.
The Complete Component
Put it all together in src/SignupForm.jsx:
import { useState } from "react";
const initialValues = { name: "", email: "", password: "" };
export function validate(values) {
const errors = {};
if (!values.name.trim()) errors.name = "Please tell us your name.";
if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "That email doesn't look right.";
if (values.password.length < 8) errors.password = "Use at least 8 characters.";
return errors;
}
export default function SignupForm() {
const [values, setValues] = useState(initialValues);
const [touched, setTouched] = useState({});
const [submitted, setSubmitted] = useState(false);
const errors = validate(values);
const isValid = Object.keys(errors).length === 0;
function handleChange(e) {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
}
function handleBlur(e) {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
}
function handleSubmit(e) {
e.preventDefault();
setTouched({ name: true, email: true, password: true });
if (!isValid) return;
console.log("Sending to server:", values);
setSubmitted(true);
setValues(initialValues);
setTouched({});
}
const showError = (field) => touched[field] && errors[field];
return (
<form onSubmit={handleSubmit} noValidate>
<h2>Create your account</h2>
<label htmlFor="name">Name</label>
<input id="name" name="name" value={values.name}
onChange={handleChange} onBlur={handleBlur} />
{showError("name") && <p role="alert">{errors.name}</p>}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" value={values.email}
onChange={handleChange} onBlur={handleBlur} />
{showError("email") && <p role="alert">{errors.email}</p>}
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" value={values.password}
onChange={handleChange} onBlur={handleBlur} />
{showError("password") && <p role="alert">{errors.password}</p>}
<button type="submit">Sign up</button>
{submitted && <p>Welcome aboard! Your account is ready.</p>}
</form>
);
}
Then show it from src/App.jsx:
import SignupForm from "./SignupForm";
export default function App() {
return <SignupForm />;
}
A quick note on noValidate: it switches off the browser's built-in popups so our own messages are the only ones the user sees. The htmlFor on each label (instead of the HTML for) is just a React quirk, because for is a reserved word in JavaScript. Pairing labels with inputs also helps screen readers and lets users click the label to focus the field.
Try It Yourself
Run npm run dev and play with it:
- Click Sign up on the empty form. All three messages appear together.
- Reload, click into the email field, type
abc and click away. Only the email complains.
- Fix everything, submit, and watch the welcome message appear while the fields clear. Open the browser console and you will see the data we "sent".
Mistakes Almost Everyone Makes at First
- Adding
value but forgetting onChange. The input becomes read-only and typing does nothing. React will warn you in the console, so read that warning.
- Forgetting
e.preventDefault(). The page reloads on submit and your state vanishes with it.
- Missing
name attributes. The shared handleChange relies on them, and without them every keystroke goes to the wrong key.
- Changing state directly. Writing
values.name = "x" will not trigger a re-render. Always create a new object, as we did with the spread operator.
- Validating only on submit. It works, but the user finds out about every problem at the very last second.
Where to Go From Here
Once this feels comfortable, here are natural next steps:
- Disable the button and show a "Signing up..." state while the request is in flight.
- Handle errors that only the server knows about, such as "this email is already registered".
- Try a library like React Hook Form when your forms grow to ten or twenty fields. Because you now understand controlled inputs, touched fields and validation, you will know exactly what the library is doing for you.
Wrapping Up
A React form is not magic. It is state that happens to be typed by a human: a values object, a touched object, errors calculated from values, and a submit handler that decides what happens next. Learn those four pieces and you can build almost any form you will meet.
Copy the component, break it on purpose, change the rules, and make it yours. That is the fastest way to make it stick. Happy coding!
Join the conversation! Your thoughts help the community grow.