Skip to main content

Forms and Inputs

Form Submission and Validation

Written by Published

Put onSubmit on the form, not onClick on 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.

  • onSubmit on the form; preventDefault first.
  • 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.