How required is supposed to work
When you add required to a form control, the browser runs constraint validation before every native form submission. If any required field is empty (or otherwise invalid), the browser blocks the submit event, focuses the first offending field, and shows a tooltip explaining what's wrong. The user cannot proceed until they fill in the field or the validation rule is satisfied.
This mechanism only works during a native form submit — that is, when the user clicks a type="submit" button that lives inside (or is associated with) the <form> element. The browser's validation pipeline is deliberately tied to this specific action. Anything that shortcuts or replaces the native submit — JavaScript methods, AJAX calls, programmatic triggers — skips validation by design. That's the root of every bug below.
<form action="/submit" method="POST">
<input type="text" name="name" required />
<input type="email" name="email" required />
<button type="submit">Send</button>
</form>That form above is the gold standard. The submit button is inside the form, it has no formnovalidate, there's no JavaScript overriding the submission, and no CSS suppressing the tooltip. Every one of the six causes below breaks one of those conditions. For the broader picture — including why forms don't submit at all — see HTML form not submitting and contact form not working.
Cause 1: JavaScript submit() bypasses validation
This is the single most common reason required appears broken. Somewhere in your code — a form handler, a state management effect, a third-party widget — JavaScript calls form.submit() directly. That method submits the form without running constraint validation. Every required, pattern, minlength, and type check is skipped.
// BROKEN — skips all validation
document.querySelector("form").submit();
// In React:
const formRef = useRef<HTMLFormElement>(null);
formRef.current?.submit();The fix is form.requestSubmit(), which was added to the DOM spec specifically for this gap. It behaves exactly like a user clicking the submit button: it runs the full validation pipeline, blocks submission if anything fails, and shows the browser's native error tooltips on invalid fields.
// FIXED — runs validation before submitting
document.querySelector("form").requestSubmit();
// In React:
formRef.current?.requestSubmit();The API is identical — same call signature, no arguments needed — but the behavior is completely different. If validation fails, requestSubmit() returns nothing and the form stays put. If everything passes, the form submits normally. Browser support is universal as of 2026 (all modern browsers, including Safari 16.4+).
Common places this bug hides: useEffect hooks that auto-submit on mount, timer-based submissions, "save and continue" flows in multi-step forms, and third-party analytics wrappers that re-submit forms. Search your codebase for .submit() — every call site is a potential bypass. For a deeper dive into programmatic validation, see how to validate HTML forms with JavaScript.
Cause 2: Submit button is outside the <form> tag
A submit button only triggers validation for the form it belongs to. By default, "belongs to" means "is a DOM descendant of." If your submit button is rendered outside the <form> element — common in component-based layouts where the button lives in a toolbar, sticky footer, or separate component — it has no association with the form and therefore triggers no validation.
<!-- BROKEN: button is a sibling, not a child -->
<form id="contact" action="/submit" method="POST">
<input type="text" name="name" required />
<input type="email" name="email" required />
</form>
<div class="toolbar">
<button type="submit">Send</button> <!-- does nothing for the form -->
</div>Three fixes, pick whichever fits your architecture:
- Use the
formattribute. Addform="formId"to the button. This is the cleanest fix — the button stays where it is in the DOM but explicitly associates with the form by ID. - Move the button inside the form. Restructure your layout so the button is a child of
<form>. If a sticky footer is the constraint, make the footer a child of the form. - Use
form.requestSubmit()from JavaScript. Wire the external button's click handler to call the form'srequestSubmit()method, which triggers full validation.
<!-- Fix 1: form attribute -->
<button type="submit" form="contact">Send</button>
<!-- Fix 3: JavaScript -->
<button type="button" onClick={() => document.getElementById("contact")?.requestSubmit()}>
Send
</button>The form attribute is supported in all modern browsers and is the standard way to handle this in component-driven UIs. In React, this attribute works normally — no special handling needed beyond setting the form's id prop.
Cause 3: formnovalidate on the submit button
The formnovalidate attribute exists for a legitimate purpose — it lets you offer a "save as draft" or "skip" button alongside the normal submit. But when it lands on your primary submit button, either by mistake or because a template was copied incorrectly, it silently disables all constraint validation for that submission.
<!-- BROKEN: validation is completely skipped -->
<form action="/submit" method="POST">
<input type="text" name="name" required />
<button type="submit" formnovalidate>Send</button>
</form>
<!-- Also broken with input -->
<form action="/submit" method="POST">
<input type="text" name="name" required />
<input type="submit" formnovalidate value="Send" />
</form>The fix is straightforward: remove the formnovalidate attribute from the primary submit button. Keep it only on secondary actions that genuinely should skip validation (draft saving, form reset combined with navigation, etc.). If you have both a "submit" and a "save draft" button, only the draft button should carry formnovalidate.
This attribute also exists on the <form> element itself as novalidate. If <form novalidate> is set, every submit from that form skips validation regardless of the button. Check both places if validation seems completely inert. If you set novalidate because you want custom JavaScript validation, remember to call form.checkValidity() or form.reportValidity() yourself.
Cause 4: Default value satisfies the check
This one is subtle but common in React and other frameworks that manage input state. The required constraint checks whether the field's value is an empty string. If the input has value="" set as a default — whether in the HTML or via a framework's state initialization — the browser considers the field to have a value (the empty string is still a value), so required passes.
<!-- BROKEN: the empty string counts as "filled" -->
<input type="text" name="name" required value="" />
<!-- In React: useState("") sets value="" on the input -->
const [name, setName] = useState("");
return <input type="text" name="name" required value={name} />;The fix depends on your framework. In plain HTML, don't set the value attribute at all — leave it absent. In React, use value= as the initial state instead of an empty string, or use defaultValue instead of value for uncontrolled inputs.
// FIXED — no value attribute means the field is genuinely empty
<input type="text" name="name" required />
// FIXED in React — use undefined so the attribute isn't rendered
const [name, setName] = useState<string | undefined>(undefined);
return <input type="text" name="name" required value={name} />;
// Alternative: use defaultValue for uncontrolled inputs
<input type="text" name="name" required defaultValue="" />This matters most in controlled React components where useState("") is the default pattern. The empty string flows into value="", the browser sees a non-empty value attribute, and required is satisfied. Switching to undefined causes React to omit the attribute entirely, which lets the browser treat the field as empty and enforce required correctly. The same logic applies to Vue's v-model with an empty initial ref and to Svelte's bind:value with an empty string default.
Cause 5: CSS hides the validation tooltip
Your form might actually be validating correctly — the submit is blocked, the field is focused — but the user sees no error feedback because your CSS is hiding the browser's native validation tooltip. Some CSS resets (normalize.css, older versions of Tailwind's preflight) include rules that suppress ::-webkit-validation-bubble or set validation-related pseudo-elements to display: none.
The result is a form that appears frozen: the user clicks submit, nothing happens, and there's no visible explanation. It's one of the most confusing failures because the underlying mechanism works — the browser is enforcing required — but the feedback channel is muted.
To diagnose, run form.reportValidity() in the console. If it returns false and focuses a field, validation is working and the problem is purely visual.
The fix is to add custom error styling using CSS pseudo-classes that work regardless of the native tooltip:
/* Highlight invalid fields after the user has interacted */
input:user-invalid {
border-color: #ef4444;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
/* Show a custom error message below the field */
input:user-invalid + .error-message {
display: block;
}
.error-message {
display: none;
color: #ef4444;
font-size: 13px;
margin-top: 4px;
}The :user-invalid pseudo-class (Baseline 2024, supported in all modern browsers) only activates after the user has interacted with the field, so you won't get jarring red borders on page load. For older browsers, fall back to the :not(:placeholder-shown):invalid chain. If you're using Tailwind, the user-invalid: variant is available in v3.4+ and v4 — see our Tailwind CSS form validation guide for the full pattern.
Important: even if you fix the CSS, the native tooltip behavior is inconsistent across browsers. Safari shows a popover, Chrome shows an inline bubble, Firefox shows a different style entirely. Custom error styling with :user-invalid gives you consistent, brandable feedback in every browser without relying on the native tooltip at all.
Cause 6: Form submitted via fetch() or XHR
If your form is submitted via fetch(), XMLHttpRequest, or any other AJAX mechanism, the browser's constraint validation is bypassed entirely. This is by design — fetch() is a generic HTTP API, not a form submission API. It doesn't know or care about your form's validation attributes.
// BROKEN — fetch() sends empty required fields
form.addEventListener("submit", (e) => {
e.preventDefault();
fetch("/api/submit", {
method: "POST",
body: new FormData(form), // required fields can be empty
});
});The fix is to explicitly check validity before the fetch call. You have two options:
// Option A: checkValidity() — silent check
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!form.checkValidity()) return; // blocks if any field is invalid
fetch("/api/submit", {
method: "POST",
body: new FormData(form),
});
});
// Option B: reportValidity() — shows browser tooltips
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!form.reportValidity()) return; // shows tooltips AND blocks
fetch("/api/submit", {
method: "POST",
body: new FormData(form),
});
});Use checkValidity() when you have custom error UI (CSS :user-invalid, inline error messages) and don't need the browser tooltip. Use reportValidity() when you want the native browser tooltips as a fallback or primary feedback. Both return true if all constraints pass and false if any fail.
This pattern applies to axios.post(), XMLHttpRequest.send(), and any other HTTP client library. The rule is the same: if you intercept the submit event with preventDefault(), you own validation from that point forward. Call checkValidity() or reportValidity() before your HTTP call.
Quick diagnostic checklist
Match your symptom to the cause, then apply the fix:
| Symptom | Cause | Fix |
|---|---|---|
| Form submits with empty fields | JavaScript submit() or fetch() | Use form.requestSubmit() or call form.checkValidity() before fetch() |
| Submit button does nothing | Button is outside the <form> tag | Add form='formId' to the button or move it inside |
| Form always submits, no errors shown | formnovalidate on the submit button | Remove the formnovalidate attribute |
| Required field passes when empty | Default value='' satisfies the check | Remove the value attribute or set it to undefined |
| Submit blocked but no tooltip visible | CSS hides the validation bubble | Add custom :invalid / :user-invalid styles |
| AJAX call fires with empty fields | fetch() / XHR bypasses validation | Call form.checkValidity() before the fetch |
If none of these match, run form.reportValidity() in the console and check the return value. If it returns false, validation is working but feedback is hidden (Cause 5). If it returns true and the form still submits with empty fields, something is stripping the required attribute at runtime — check the rendered DOM, not your source code.
FAQ
Why does my form submit even with empty required fields?
Most likely cause: JavaScript is calling form.submit() or fetch() to send the data, both of which bypass the browser's constraint validation entirely. Switch to form.requestSubmit() (triggers validation before submitting) or call form.checkValidity() before your fetch() call. See Cause 1 and Cause 6 below for the code changes.
How do I check if required validation is working?
Leave a required field empty and click the submit button. If the form submits anyway, validation is broken — something is bypassing the browser's constraint check. If the form doesn't submit but you see no error message, validation is working but the feedback is being hidden (Cause 5). You can also run form.reportValidity() in the browser console — it returns true if all fields pass and false if any fail.
What's the difference between form.submit() and form.requestSubmit()?
form.submit() immediately submits the form without running any constraint validation — required, pattern, type, and minlength are all skipped. form.requestSubmit() does everything form.submit() does but runs the browser's full validation pipeline first, blocking submission and showing tooltips if anything fails. If you need to submit programmatically, requestSubmit() is almost always what you want.
Can I make required work with fetch()?
Not automatically — fetch() and XMLHttpRequest bypass the browser's constraint validation entirely because they are not native form submissions. The fix is to call form.checkValidity() (or form.reportValidity() if you want tooltips) before your fetch() call, and only proceed if it returns true. See Cause 6 for the exact pattern.
Why doesn't the browser show an error tooltip?
Two common reasons. First, your CSS (or a CSS reset like normalize.css or Tailwind's preflight) may be suppressing ::-webkit-validation-bubble or setting display: none on validation messages. Second, you may not be using native form submit — if JavaScript calls preventDefault() and then uses fetch(), the browser's tooltips never fire because the native submission never happens. Use CSS :invalid and :user-invalid pseudo-classes for custom error styling that works regardless.
Does required work with hidden inputs?
No — the HTML spec explicitly excludes hidden inputs from constraint validation. Adding required to <input type='hidden'> has no effect. The same applies to inputs with display: none, inputs inside a display: none parent, and inputs with the disabled attribute. If you need to validate a hidden value, check it in JavaScript before submitting.
Can I customize the required error message?
Yes — use the setCustomValidity() method on the input element. Call it from an oninvalid handler with your custom message string, and clear it with an empty string from an oninput handler so the field can revalidate after the user fixes it. The custom message replaces the browser's default tooltip text. For more validation techniques, see our guide on how to validate HTML forms with JavaScript.
Once validation works, you need somewhere to send the data. Get a free splitforms access key and point your form at https://splitforms.com/api/submit — submissions are emailed to you and logged in a dashboard with spam filtering built in.
Related reading
- How to validate HTML forms with JavaScript — programmatic validation with checkValidity, reportValidity, and custom rules.
- HTML form not submitting — 8 markup bugs that prevent submission entirely.
- Contact form not working — full end-to-end debugging guide from markup to email delivery.