Build a login form with structure, validation, and state management
Controlled inputs for email/password, validation (on blur + submit), per-field error state, submit handling with loading/error states, disabled submit while submitting. Use a <form> with onSubmit, proper input types/labels, and never trust client validation alone — the server re-validates.
A login form is small but interviewers grade the details: validation timing, all the states, accessibility, and form semantics.
The implementation
function LoginForm({ onLogin }) {
const [values, setValues] = useState({ email: "", password: "" });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [status, setStatus] = useState("idle"); // idle|submitting|error
const validate = (vals) => {
const e = {};
if (!vals.email) e.email = "Email is required";
else if (!/^[^@]+@[^@]+\.[^@]+$/.test(vals.email)) e.email = "Invalid email";
if (!vals.password) e.password = "Password is required";
else if (vals.password.length < 8) e.password = "Min 8 characters";
return e;
};
const handleChange = (e) => {
const next = { ...values, [e.target.name]: e.target.value };
setValues(next);
if (touched[e.target.name]) setErrors(validate(next)); // re-validate once touched
};
const handleBlur = (e) => {
setTouched((t) => ({ ...t, [e.target.name]: true }));
setErrors(validate(values));
};
const handleSubmit = async (e) => {
e.preventDefault();
const e2 = validate(values);
setErrors(e2);
setTouched({ email: true, password: true });
if (Object.keys(e2).length) return;
setStatus("submitting");
try {
await onLogin(values);
} catch (err) {
setStatus("error");
setErrors({ form: "Invalid email or password" }); // generic — don't leak which
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" value={values.email}
onChange={handleChange} onBlur={handleBlur}
aria-invalid={!!errors.email} aria-describedby="email-err" />
{touched.email && errors.email && <span id="email-err">{errors.email}</span>}
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" value={values.password}
onChange={handleChange} onBlur={handleBlur} />
{touched.password && errors.password && <span>{errors.password}</span>}
{errors.form && <div role="alert">{errors.form}</div>}
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Signing in…" : "Sign in"}
</button>
</form>
);
}What's being graded
- A real
<form>withonSubmit+e.preventDefault()— so Enter submits. - Controlled inputs, correct
type(email,password),<label htmlFor>linked toid. - Validation timing — validate on blur first (don't yell mid-typing), then re-validate on change once the field is touched, and everything on submit.
- All states —
idle/submitting(disable button, spinner) /error. - Field errors inline + a form-level error for failed auth — and that error should be generic ("invalid email or password"), never "wrong password" (don't reveal which field is wrong — account enumeration).
- Accessibility —
aria-invalid,aria-describedby,role="alert"on the form error. - Server re-validates — client validation is UX only.
For production: a form library (React Hook Form) + schema (Zod), and proper autocomplete attributes.
The framing
"Controlled email/password inputs in a real <form> with onSubmit so Enter works. Validation timing is the nuance — validate on blur first, re-validate on change once touched, and everything on submit. I model status as idle/submitting/error, disable the button while submitting, show inline field errors plus a generic form-level error for auth failure — never 'wrong password' specifically, to avoid account enumeration. Accessibility via aria-invalid/describedby and role=alert. And client validation is purely UX — the server re-validates."
Follow-up questions
- •When should each field validate — change, blur, or submit?
- •Why should the auth-failure message be generic?
- •What ARIA attributes does an accessible form need?
- •Why use a <form> element instead of a div with a button?
Common mistakes
- •A div + button instead of a form — Enter doesn't submit.
- •Validating aggressively on every keystroke from the start.
- •Revealing whether the email or the password was wrong.
- •No loading state / not disabling the button while submitting.
- •Missing labels and ARIA — inaccessible.
- •Treating client validation as sufficient.
Performance considerations
- •Trivial scale. Controlled inputs re-render the form per keystroke — fine here; a form library's uncontrolled approach matters more for large forms.
Edge cases
- •Submitting with empty fields.
- •Server rejects after client validation passed.
- •Rapid double-submit.
- •Password managers autofilling the fields.
- •Network failure during submit.
Real-world examples
- •Every app's sign-in page.
- •React Hook Form + Zod powering production login/signup forms.