Prevent Default Form Submission Without Losing Validation

This recipe shows how to intercept a form’s submit event with event.preventDefault() for SPA routing or a fetch request while still running the browser’s Constraint Validation API — so invalid forms never slip through to the network and users still receive accessible, on-brand error feedback.

When to Use This Recipe

Reach for this pattern whenever a scripted submission needs to coexist with native constraints:

  • You are submitting via fetch or routing through a client-side router and must stop the default full-page navigation.
  • You still want the declarative power of required, type="email", pattern, min/max, and friends, rather than re-implementing those rules by hand.
  • You want full control over error presentation (custom messaging, focus, live regions) instead of the browser’s blocking popups.

If you genuinely want the browser’s default navigation and native popups, you do not need this recipe at all — just omit the handler. This page is for the common middle ground: scripted submission, native validation rules.

The canonical house style is <form novalidate> plus a manual reportValidity() call. The novalidate attribute suppresses the browser’s native blocking popups while leaving the entire Constraint Validation API intact, so checkValidity() and reportValidity() still work exactly as documented in the Constraint Validation API Deep Dive. This recipe is one phase of the broader Form Submission Lifecycle.

Prevent-default validation decision flow On submit, preventDefault runs, then checkValidity is consulted. Invalid forms call reportValidity and stop. Valid forms dispatch the request. submit event preventDefault() checkValidity false reportValidity + stop true dispatch fetch / router
Prevent default unconditionally, then let the validity gate decide between reporting errors and dispatching the request.

Minimal Working Implementation

Bind the listener to the <form> element’s submit event — never to a button’s click, which bypasses keyboard (Enter) submission and breaks the standard flow. With novalidate in place, reportValidity() is what renders messaging and moves focus to the first invalid field.

<form id="signup" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required autocomplete="email" />

  <label for="pw">Password</label>
  <input id="pw" name="pw" type="password" required minlength="8" />

  <button type="submit">Create account</button>
  <p data-form-status aria-live="polite"></p>
</form>
const form = document.querySelector<HTMLFormElement>('#signup')!;

form.addEventListener('submit', async (event: SubmitEvent) => {
  // 1. Always stop the native navigation — this is a scripted submission.
  event.preventDefault();

  // 2. The validity gate. reportValidity() surfaces messaging AND focuses
  //    the first invalid control, because novalidate suppresses the popups
  //    but keeps the Constraint Validation API fully live.
  if (!form.reportValidity()) {
    return; // invalid: messaging shown, focus moved, nothing dispatched
  }

  // 3. Valid: proceed with the fetch or router navigation.
  await dispatchSignup(form);
});

async function dispatchSignup(form: HTMLFormElement): Promise<void> {
  const status = form.querySelector<HTMLElement>('[data-form-status]')!;
  const body = JSON.stringify(Object.fromEntries(new FormData(form)));
  const res = await fetch('/api/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body,
  });
  status.textContent = res.ok ? 'Account created.' : 'Something went wrong. Please retry.';
}

The crucial detail is that preventDefault() and the validity gate are independent. reportValidity() does not submit anything; it only evaluates constraints and surfaces feedback. So you can safely call preventDefault() first and let the boolean return value of reportValidity() gate the dispatch.

Handling Async and Dynamically Injected Fields

Native validation covers only HTML5 constraints. When business logic needs a remote check, run the synchronous gate first, then await the remote call, and feed its result back through setCustomValidity() so the failure flows through the same accessible channel. The remote step is exactly the kind of work covered by asynchronous server checks.

form.addEventListener('submit', async (event: SubmitEvent) => {
  event.preventDefault();
  if (!form.reportValidity()) return; // synchronous constraints first

  const email = form.elements.namedItem('email') as HTMLInputElement;
  try {
    const available = await isEmailAvailable(email.value);
    if (!available) {
      email.setCustomValidity('That email is already registered.');
      email.reportValidity();   // announce + focus this specific field
      email.setCustomValidity(''); // clear so the next keystroke is not blocked
      return;
    }
    await dispatchSignup(form);
  } catch {
    const status = form.querySelector<HTMLElement>('[data-form-status]')!;
    status.textContent = 'Could not verify your email. Please try again.';
  }
});

For fields appended after load, you do not need to re-register anything: the Constraint Validation API automatically tracks newly added form-associated elements, so the next checkValidity() / reportValidity() call includes them. Use event.submitter to branch on which button was pressed (for example, a “save draft” button with relaxed rules) without breaking the gate.

Option Reference

API Returns Shows UI? Moves focus? Use it for
event.preventDefault() void no no stopping native navigation
form.checkValidity() boolean no no a silent pass/fail check
form.reportValidity() boolean yes yes (first invalid) the canonical validity gate
input.setCustomValidity(msg) void no (until reported) no injecting an async/business error
event.submitter element | null branching per submit button

Verification Steps

In DevTools, confirm the handler is wired correctly:

  1. Submit with an empty required field. You should see your error UI and the first invalid field focused — proof reportValidity() ran under novalidate.
  2. In the console, run document.querySelector('#signup').checkValidity() and [...document.querySelectorAll(':invalid')].map(el => el.name) to confirm the DOM-level validity matches your UI.
  3. Watch the Network tab: no request should appear until every constraint passes.
// Playwright: invalid input must not dispatch a request
import { test, expect } from '@playwright/test';

test('blocks dispatch until the form is valid', async ({ page }) => {
  await page.goto('/signup');
  let requested = false;
  page.on('request', (r) => { if (r.url().includes('/api/signup')) requested = true; });

  await page.click('button[type="submit"]'); // empty form
  expect(requested).toBe(false);
  await expect(page.locator('#email')).toBeFocused();
});

Edge Cases & Failure Modes

Capturing-phase listeners. Registering with { capture: true } runs your handler before the bubbling phase other code may rely on. Keep the default (capture: false) unless you have a specific reason, and never assume capture changes when native validation runs — under novalidate you own validation entirely.

Forgetting to clear setCustomValidity. A non-empty custom message keeps the field :invalid forever, silently blocking every future submission. Always reset it with setCustomValidity('') once the condition no longer holds, as detailed in How to Use setCustomValidity Correctly.

<button type="button"> inside the form. Such a button never submits and never triggers validation. If your “submit” control isn’t firing the gate, confirm it is type="submit". Conversely, secondary actions should be explicitly type="button" so they don’t submit by accident.

Accessibility Notes

reportValidity() automatically updates aria-invalid and announces the active error to assistive technology, then focuses the first invalid control — which is precisely what Managing Focus After Validation Failure recommends. If you ever replace the native bubble with custom UI, you must reproduce that behavior yourself: associate the message via aria-describedby, set aria-invalid="true", and move focus to the offending field to meet WCAG Focus Order (SC 2.4.3).

Frequently Asked Questions

Does preventDefault() stop native validation from running?

No. preventDefault() only cancels the default navigation. The Constraint Validation API is queried entirely through your own checkValidity() / reportValidity() calls, which are independent of the event's default action. With novalidate there is no automatic native pass at all, so nothing is lost.

Why bind to the form's submit event instead of the button's click?

Pressing Enter in a text field submits the form without ever clicking the button, so a click handler silently misses keyboard submissions. Binding to submit captures every trigger and preserves native semantics, including which control is the event.submitter.

My form is permanently blocked even after I fix the input — why?

Almost always a stale custom message. If you called setCustomValidity('some error') and never reset it, the field stays :invalid regardless of its value. Clear it with setCustomValidity('') whenever the error condition no longer applies.

← Back to Form Submission Lifecycle