- Home
- /
- Tutorials
- /
- React Tutorial
- /
- Form Submission and Validation
Forms and Inputs
Form Submission and Validation
Put
onSubmiton the form, notonClickon the button. That one choice gets you Enter-to-submit and browser validation for free.
Submitting
A form submits when the button is clicked or Enter is pressed in a field. Handling it on the form catches both; handling the button's click catches only one, and keyboard users notice.
onSubmit and preventDefault
jsx
function Signup() {
const [email, setEmail] = React.useState("")
const [sent, setSent] = React.useState(null)
function handleSubmit(event) {
event.preventDefault() // stop the browser reloading the page
setSent(email)
setEmail("")
}
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>{" "}
<button type="submit">Sign up</button>
{sent && <p style={{ color: "#15803d" }}>Submitted: {sent}</p>}
<p style={{ color: "#64748b" }}>Press Enter in the field — it submits too.</p>
</form>
)
}
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Signup />)Without preventDefault the browser does what forms have always done: serialises the fields and reloads the page, wiping your state. It is not React-specific, and it catches everyone once.
Validating
Validation is a function from the form values to a set of errors. Keeping it separate from the JSX means it can be read, tested and reused without untangling it from markup.
Errors after the user has tried
jsx
function validate({ email, password }) {
const errors = {}
if (!email.includes("@")) errors.email = "Needs an @"
if (password.length < 8) errors.password = "At least 8 characters"
return errors
}
function Form() {
const [values, setValues] = React.useState({ email: "", password: "" })
const [touched, setTouched] = React.useState({})
const errors = validate(values)
const update = (field, value) => setValues((v) => ({ ...v, [field]: value }))
const touch = (field) => setTouched((t) => ({ ...t, [field]: true }))
return (
<form onSubmit={(e) => e.preventDefault()} style={{ display: "grid", gap: 8, maxWidth: 320 }}>
<div>
<input
value={values.email}
onChange={(e) => update("email", e.target.value)}
onBlur={() => touch("email")}
placeholder="Email"
/>
{touched.email && errors.email && (
<small style={{ color: "#b91c1c" }}> {errors.email}</small>
)}
</div>
<div>
<input
type="password"
value={values.password}
onChange={(e) => update("password", e.target.value)}
onBlur={() => touch("password")}
placeholder="Password"
/>
{touched.password && errors.password && (
<small style={{ color: "#b91c1c" }}> {errors.password}</small>
)}
</div>
<button type="submit" disabled={Object.keys(errors).length > 0}>
Submit
</button>
</form>
)
}
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Form />)Disabling the button is not enough
A disabled submit button stops the obvious path and nothing else. The form still submits on Enter in some browsers, and anyone can re-enable the button from devtools - so the handler must check validity itself rather than trusting that it was unreachable.
The same reasoning is why client validation never replaces server validation. It exists to give fast, friendly feedback; the server is the only place that decides what is actually allowed.
Why touched matters
Errors are computed on every render, but shown only for fields the user has left. Without that, an empty form greets people with red text before they have typed a character - technically accurate and hostile.
Note that errors is derived during render, not stored in state. It is computed from the values, so storing it would be the duplicate-state mistake from the earlier chapter.
onSubmiton the form;preventDefaultfirst.- Give the submit button
type="submit". - Derive errors; do not store them.
- Show an error only after
onBlur, or after a submit attempt. - Client validation is for helpfulness - the server still has to check.
