skip to content

Form State and Validation Patterns

Beyond a single input, forms need a state shape, a validation moment, and a submit path. Interviewers want to hear when per-field state stops scaling, why validating on blur beats validating on every keystroke, and what a form library buys you.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In a React component, why does submitting a <form> reload the page, and what must the onSubmit handler do to stop that?

level: juniorimportance: must knowfreq 70%

answer

  1. a form submit is a navigation
  2. no action attribute still submits
  3. first line of the handler
  4. handler on the form, not the button
  5. every non-submit button needs a type

basics

~20 s

The browser's default form submission navigates, which reloads the page and throws away React state. Call event.preventDefault() as the first thing in the form's onSubmit handler, then read your state and send the request yourself.

solid answer

~50 s

Submitting a form is a browser navigation: with no `action` attribute it re-requests the current URL, the page reloads, and every piece of React state is gone. React does not suppress that for you when you use `onSubmit` — the handler runs, then the default proceeds. So the first line of the handler is `event.preventDefault()`, and everything after it is yours: read the values out of state, run validation, call the API, set a pending flag so the button can be disabled against double submits. I put the handler on the `<form>` and not on the button's `onClick`, because `onSubmit` catches every path into a submission — Enter in a text field, a click on the submit button, a programmatic `requestSubmit()` — and it is the one place `preventDefault` belongs. In React 19 there is also an `action` prop that takes a function, and React handles the default itself in that case.

code

javascript · 35 lines
javascript
import { useState } from 'react';

export default function ContactForm() {
  const [message, setMessage] = useState('');
  const [pending, setPending] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(event) {
    event.preventDefault();
    setPending(true);
    setError(null);
    try {
      const res = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message }),
      });
      if (!res.ok) throw new Error('Request failed');
      setMessage('');
    } catch (err) {
      setError('Could not send. Please try again.');
    } finally {
      setPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea value={message} onChange={e => setMessage(e.target.value)} />
      {error && <p>{error}</p>}
      <button type="submit" disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
      <button type="button" onClick={() => setMessage('')}>Clear</button>
    </form>
  );
}

go deeper

for a junior

Be able to write the handler on the spot: event.preventDefault() first, then read state and send. Say plainly that a form submit is a browser navigation that would wipe React state.

for a middle

Explain why the handler belongs on the form rather than the button — Enter, clicks and requestSubmit all funnel through onSubmit — and why an untyped button inside a form submits it.

for a senior

Cover the full submit lifecycle you would ship: whole-form validation, a pending flag cleared in a finally, an error path that preserves the user's input, and protection against duplicate submissions.

for a principal

Own the pattern across the product: one submit shape every form follows, a decision on whether new forms use the onSubmit path or React 19's action prop, and the rule that server-side validation is mandatory regardless of what the client does.

## What the default actually is A `<form>` is a navigation element. Submitting it makes the browser build a request from the form's fields and navigate to the URL in its `action` attribute using its `method`. If there is no `action`, the target is the current URL. Either way the document unloads and a fresh one is loaded, which for a single-page React app means the component tree is destroyed and rebuilt, every `useState` value is back to its initial value, and any half-finished work is gone. What the user perceives is "the page flashed and my form is empty". This is HTML behaviour, not React behaviour. It predates you and it is the default because forms worked this way before JavaScript existed. ## Preventing it ```jsx function handleSubmit(event) { event.preventDefault(); // now it's an ordinary function call if (!isValid(values)) return; submitToApi(values); } return <form onSubmit={handleSubmit}>…</form>; ``` `event.preventDefault()` cancels the browser's default action for that event. React does not do this implicitly for `onSubmit`; your handler runs and then the navigation happens unless you stop it. The event object React hands you is a SyntheticEvent, and `preventDefault` on it does what you expect on the underlying native event. Call it first, unconditionally. A handler that only prevents the default on the validation-failure path will still navigate away on the success path, which is a bug that hides behind "well, it worked when the data was wrong". ## Put the handler on the form A common alternative is `onClick` on the submit button. It mostly works, and that is what makes it dangerous. There are several ways to submit a form: clicking a submit button, pressing Enter in a single-line text field ("implicit submission", which browsers usually route through a synthetic click on the default submit button — but not when the form has no submit button), and calling `form.requestSubmit()` from code. `onSubmit` on the form is the single funnel every one of these passes through. It is also where `preventDefault` semantically belongs: you are cancelling a *submission*, not a *click*. A related trap lives in the same neighbourhood: a `<button>` inside a form with no `type` attribute defaults to `type="submit"`. So a button whose `onClick` opens a modal or adds a row will *also* submit and reload the form. Any button in a form that is not the submit button needs `type="button"` explicitly. ## After preventDefault Once the default is cancelled, the submit path is ordinary React code: - validate the whole values object, not only the fields the user touched, and bail out early if it fails; - set a pending flag so you can disable the submit button and render a spinner — otherwise an impatient user fires the request two or three times; - `await` the request and handle both outcomes: on failure, surface a form-level error and keep the values so the user does not retype them; on success, reset or navigate; - clear the pending flag in both branches, typically in a `finally`, or the button stays disabled forever after an error. ```jsx async function handleSubmit(event) { event.preventDefault(); setPending(true); try { await submitToApi(values); } catch (err) { setFormError('Could not save. Please try again.'); } finally { setPending(false); } } ``` ## Do not forget the server Cancelling the default submission means the request now leaves on your terms — your URL, your headers, your JSON body. It does not mean the data has been checked. Whatever the client validated, the server validates again. ## The React 19 alternative React 19 also lets you pass a *function* to a form's `action` prop rather than a URL. In that mode React calls your function when the form is submitted and takes care of the default navigation itself, so there is no `preventDefault` to write. It is a different submission model with its own pending and result plumbing; the `onSubmit` + `preventDefault` pattern described here is still completely valid, and it is what you will find in most existing code.

  • Why put the handler on the form's onSubmit rather than on the submit button's onClick?
    Because onSubmit is the funnel every submission path goes through: a click on the submit button, Enter pressed in a single-line text input, and a programmatic form.requestSubmit(). Enter usually does route through a click on the default submit button, but a form with no submit button, or a scripted submission, bypasses onClick entirely. preventDefault also belongs on the submission, not on a click that happens to cause one.
  • A button inside the form only opens a modal, yet clicking it reloads the page. What is wrong?
    The button has no type attribute, and the HTML default for a button inside a form is type="submit". So its onClick runs, then the form submits and the browser navigates. Give every button in a form that is not the submit button an explicit type="button". This is the single most common accidental-submit bug in React forms.
  • How do you stop an impatient user from submitting the same form three times?
    Keep a pending flag in state, set it right after preventDefault, and disable the submit button while it is true, clearing it in a finally so a failed request does not leave the button dead. That covers the UI side; because a determined client can still send duplicates, anything that must happen exactly once also needs an idempotency guard on the server.

saying these in an interview costs you the question

  • Thinks React cancels the default submission for you
  • Says a form without an action attribute does not submit
  • Calls preventDefault only on the validation-failure path
  • Uses onClick on the button as the submit handler
  • Leaves other buttons in the form without type="button"
  • Never disables the button while the request is in flight

context

open as a page

A React sign-up form has grown to a dozen inputs, each with its own useState call. What do you gain and what do you give up by collapsing them into a single state object updated by one generic change handler?

level: middleimportance: should knowfreq 62%

basics

~20 s

One state object plus a name-keyed change handler removes a dozen useState calls and makes reset, prefill and submit trivial. The price is spreading the previous object on every update and losing per-field bail-outs; re-render counts barely change.

open as a page

In a React form, why is running validation on every keystroke a poor default, and what extra state do you keep so a field's error appears at the right moment?

level: middleimportance: should knowfreq 55%

basics

~20 s

Validating on every keystroke shows an error before the user has finished typing, so a half-typed email looks broken. Track a per-field touched flag set on blur, plus a submitted flag, and render an error only once one of them is true.

open as a page

Your team's React forms are all hand-rolled with useState, and someone proposes adopting React Hook Form. What does a form library actually give you, and how would you decide whether it is worth adopting?

level: principalimportance: should knowfreq 38%

basics

~20 s

A form library owns values, touched and dirty flags, errors and the submit lifecycle in one store, wires validation to a shared schema, and avoids re-rendering the whole form per keystroke. For one two-field form it is pure overhead; the case is made by many forms, not by one.

open as a page