Multi-field forms and submit
A signup form has several fields. You could call useState per field, which is fine for two or three. Past that, one object keeps them together, updated with the spread pattern from lesson 4-3:
function Signup() { const [form, setForm] = useState({ email: "", password: "" }); function update(field, value) { setForm({ ...form, [field]: value }); } return ( <form onSubmit={handleSubmit}> <input value={form.email} onChange={e => update("email", e.target.value)} /> <input type="password" value={form.password} onChange={e => update("password", e.target.value)} /> <button>Sign up</button> </form> ); }
{ ...form, [field]: value } copies the object and overwrites one field, using the computed property name syntax from Advanced JavaScript.
Handling submit
A browser submits forms by navigating to a new page, a full reload. In a React app you almost never want that, so the submit handler's first line stops it:
function handleSubmit(e) { e.preventDefault(); const errors = validate(form); if (errors.length > 0) { setErrors(errors); return; } // send form to the server (Unit 7 covers fetching) }
e.preventDefault() is the same DOM method from Web Development Fundamentals. Notice validate is a plain function taking the form object and returning an array of error strings. Keeping validation out of the component makes it testable, and it is exactly the kind of logic we CAN run right here.
Validation as a plain function
validate(form) returns an array of error strings, and an empty array means everything passed.
function validate(form) { const errors = []; if (form.email.trim() === "") errors.push("Email is required"); else if (!form.email.includes("@")) errors.push("Email must contain @"); if (form.password.length < 8) errors.push("Password must be 8+ characters"); return errors; } console.log(validate({ email: "", password: "hunter2" }).join(" | ")); console.log(validate({ email: "amara.dev", password: "longenough1" }).join(" | ")); console.log(validate({ email: "amara@dev.io", password: "longenough1" }).length);
Output
Email is required | Password must be 8+ characters Email must contain @ 0
The two email rules are joined by else if so a blank email produces only the required error. Reporting both "required" and "must contain @" for an empty box is technically true and reads as noise, and the chained form encodes that judgment.
The password check is an independent if, which is why the first line shows both an email error and a password error. Independent rules get independent if statements, and related rules get chained ones, and choosing between them is a small design decision per field.
form.email.trim() === "" catches a box holding only spaces, which a plain === "" check would let through. That is the invisible-whitespace problem again, and forms are where it shows up most.
The function takes an object and returns an array, touching no state and no DOM, which makes it directly testable. That is the payoff of keeping validation outside the component, and it is why this block can run here at all while the surrounding JSX cannot.
Note that returning an array rather than a boolean is what lets the UI list every problem at once. A isValid boolean would force the user to fix one field per attempt, which is the worst form experience there is.
Why submit handlers start with preventDefault
e.preventDefault() exists to stop the browser's default form submission, which would reload the page and wipe all React state.
The browser default for a form submit is a navigation, meaning a full page load. That destroys the running React app along with every piece of state, so the form's own contents disappear along with everything else.
The symptom is distinctive and confusing the first time. The page appears to flash and reset, and the network tab shows a document request rather than the API call you intended, and none of your handler's later lines seem to have run.
preventDefault is the same DOM method from Web Development Fundamentals, and it cancels the navigation so your JavaScript handles the data instead. It belongs on the first line, before validation, because an early return from a failed check would otherwise let the navigation through.
Note that the <button> inside a <form> submits by default, which is usually what you want, since it makes the Enter key work. The alternative of type="button" plus an onClick avoids the whole issue and loses keyboard submission, which is an accessibility regression.
Why the spread is required in the update helper
Writing setForm({ [field]: value }) without the spread means the other fields would be lost.
useState setters replace the whole value rather than merging into it. { [field]: value } is an object with exactly one field, so typing in the email box would produce state like { email: "a" }, and password would simply vanish.
The failure is immediate and total. The next render reads form.password as undefined, the password input's value becomes undefined, and React switches that input to uncontrolled mid-life, which produces a console warning on top of the lost data.
| Setter call | Resulting state |
|---|---|
setForm({ ...form, email: "a" }) | both fields, email updated |
setForm({ email: "a" }) | email only, password gone |
The merge behavior people expect here comes from class components' setState, which did merge objects. useState deliberately does not, and the spread is how you opt into merging, which keeps the rule simple at the cost of one extra token.
The computed property name [field] is the other half of the helper, and it is from Advanced JavaScript. It lets one function update any field by name, so a ten-field form still needs only this one helper rather than ten handlers.