# Lean on Native Constraint Validation

**SHOULD** · **ID:** `forms-native-validation` · **Category:** forms
**Source:** [Rauno](https://interfaces.rauno.me/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/forms-native-validation

> SHOULD: Lean on native constraint validation (`required`, `type="email"`, `pattern`, `min`, `minlength`) instead of hand-rolled JS checks; drop `noValidate` and reach for `setCustomValidity` only for rules HTML cannot express.

Use required, type, pattern, min and max so the browser validates instead of hand-rolling every check in JS

The platform already ships constraint validation: required, type="email", pattern, min, max and minlength are enforced by the browser on every submit path — click, Enter, autofill, programmatic — and it focuses the first offending control and announces the message to assistive tech for free. Hand-rolled JS checks miss those paths (a keyup handler never fires on a mouse paste), and hand-rolled email regexes routinely reject valid addresses like ada+work@example.com. Keep the submit button enabled and drop noValidate; reach for the Constraint Validation API (setCustomValidity, ValidityState) only for the rules HTML cannot express.

## Bad — do not do this

`forms-native-validation-bad`

```tsx
import { useState } from 'react';

// A hand-rolled "is this an email" regex. Like most of them, it is wrong:
// it rejects the perfectly valid plus-addressing that ada+work@example.com uses.
const EMAIL_RE = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9-]+\.(com|org|net)$/;

export function NativeValidationBad() {
  const [name, setName] = useState('Ada Kowalski');
  const [email, setEmail] = useState('ada+work@example.com');
  const [error, setError] = useState('Enter a valid email address');

  // Only runs on keyup, so autofill, mouse-paste and programmatic fills
  // never re-validate and the error just sits there.
  const validateOnKeyUp = (value: string) => {
    setError(EMAIL_RE.test(value) ? '' : 'Enter a valid email address');
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (error) return; // submission silently blocked by our own bad check
    alert('Account created');
  };

  return (
    <div className="w-full max-w-sm">
      {/* noValidate switches off every constraint the browser would have checked for free. */}
      <form onSubmit={handleSubmit} noValidate className="space-y-4">
        <div>
          <label htmlFor="bad-native-name" className="block text-sm font-medium text-foreground mb-1">
            Full name
          </label>
          <input
            id="bad-native-name"
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
        </div>
        <div>
          <label htmlFor="bad-native-email" className="block text-sm font-medium text-foreground mb-1">
            Work email
          </label>
          <input
            id="bad-native-email"
            type="text"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            onKeyUp={(e) => validateOnKeyUp(e.currentTarget.value)}
            className={`w-full px-3 py-2 border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
              error ? 'border-error/50' : 'border-border'
            }`}
          />
          {error && <p className="text-xs text-error mt-1">{error}</p>}
        </div>
        <button
          type="submit"
          className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
        >
          Create account
        </button>
      </form>
      <p className="text-xs text-error mt-4">
        noValidate plus a hand-rolled regex: a valid address is rejected, the empty-name case is
        never caught, and Submit does nothing at all.
      </p>
    </div>
  );
}
```

## Good — do this

`forms-native-validation-good`

```tsx
import { useState } from 'react';

export function NativeValidationGood() {
  const [submitted, setSubmitted] = useState(false);

  // Only reached once the browser's own constraint validation has passed.
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setSubmitted(true);
  };

  return (
    <div className="w-full max-w-sm">
      {/* No noValidate: required / type / min / max are checked by the browser,
          which focuses the first offending field and surfaces its own message. */}
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label htmlFor="good-native-name" className="block text-sm font-medium text-foreground mb-1">
            Full name
          </label>
          <input
            id="good-native-name"
            name="name"
            type="text"
            required
            autoComplete="name"
            defaultValue="Ada Kowalski"
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
        </div>
        <div>
          <label htmlFor="good-native-email" className="block text-sm font-medium text-foreground mb-1">
            Work email
          </label>
          <input
            id="good-native-email"
            name="email"
            type="email"
            required
            spellCheck={false}
            autoComplete="email"
            defaultValue="ada+work@example.com"
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
        </div>
        <div>
          <label htmlFor="good-native-seats" className="block text-sm font-medium text-foreground mb-1">
            Seats
          </label>
          <input
            id="good-native-seats"
            name="seats"
            type="number"
            required
            min={1}
            max={20}
            defaultValue={3}
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
        </div>
        <button
          type="submit"
          className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
        >
          Create account
        </button>
      </form>
      {submitted && (
        <p className="text-xs text-success mt-2" role="status">
          Account created.
        </p>
      )}
      <p className="text-xs text-success mt-4">
        required + type=&quot;email&quot; + min/max: the plus-addressed email is accepted, Submit stays
        enabled, and clearing a field lets the browser report it. Try it.
      </p>
    </div>
  );
}
```

## References

- [MDN: required attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/required)
- [MDN: Client-side form validation](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation)
- [MDN: ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState)
