Skip to content
Chapter 44Lesson 6

The Constraint Validation API

The browser's native Constraint Validation API, the cheap pre-submit layer that catches shape errors before they reach your Server Action.

Your invoice form is wired: the user types an amount, hits submit, your Server Action safeParses the payload with Zod, and useActionState paints the field errors. Submit with the amount left blank, though, and watch the cost: the POST flies to the server, the schema rejects it, and 200 milliseconds later the field lights up with “Required.” The browser knew that field was empty before a single byte left the page.

This lesson adds the browser’s own validation, which runs before anything hits the wire. You’ll add cheap native checks that fire before submit, show the invalid state only after the user has engaged a field, and swap the browser’s gray tooltip for your design system’s inline error. The hard part is knowing which rules belong where: in the browser, in your Zod schema, or in the action body alone. One principle holds it together: constraint validation is the cheap layer, and the server is the boundary of correctness.

The attributes the browser already validates

Section titled “The attributes the browser already validates”

You already know these attributes: required, type, min, and maxlength are plain HTML. What changes here is that you place them deliberately, because each one is a rule the browser enforces for free, before the network, with no JavaScript.

These attributes, plus a JavaScript API you’ll meet later, make up the Constraint Validation API . Here it is on the invoice form’s inputs.

<input
name="amount"
type="number"
min="0"
step="0.01"
required
/>
<input name="reference" type="text" pattern="INV-\d{4}" required />
<input name="email" type="email" required />
<input name="dueDate" type="date" min={today} required />
<textarea name="note" maxLength={500} />

The amount field. On submit the browser refuses to proceed if the field is empty (required), if the value is negative (min="0"), or if it isn’t a whole multiple of one cent (step="0.01"). All three run before the action is called.

<input
name="amount"
type="number"
min="0"
step="0.01"
required
/>
<input name="reference" type="text" pattern="INV-\d{4}" required />
<input name="email" type="email" required />
<input name="dueDate" type="date" min={today} required />
<textarea name="note" maxLength={500} />

The note. maxLength is a hard cap: the browser won’t let the user type past 500 characters. Its sibling minLength reports a “too short” violation on submit instead.

<input
name="amount"
type="number"
min="0"
step="0.01"
required
/>
<input name="reference" type="text" pattern="INV-\d{4}" required />
<input name="email" type="email" required />
<input name="dueDate" type="date" min={today} required />
<textarea name="note" maxLength={500} />

The reference. pattern holds the value to a regular expression: the browser blocks submit unless the value is a full match for INV-\d{4}, meaning exactly INV- followed by four digits.

<input
name="amount"
type="number"
min="0"
step="0.01"
required
/>
<input name="reference" type="text" pattern="INV-\d{4}" required />
<input name="email" type="email" required />
<input name="dueDate" type="date" min={today} required />
<textarea name="note" maxLength={500} />

The email and due-date fields. type="email" rejects anything that isn’t a plausible address. type="date" rejects an invalid date and, paired with min, anything earlier than the floor you set (today is an ISO date string from the server).

1 / 1

In JSX the length attribute is maxLength, the camelCase DOM property, even though the HTML attribute is maxlength. The same goes for minLength, inputMode, and autoComplete. The lowercase min, max, step, pattern, type, required, and name keep their HTML casing.

Two attributes often sit on these same inputs but are not validation: inputMode (which keyboard a phone pops up) and autoComplete (the autofill hint). They never reject input, only shape the experience; a later section covers them.

Two mirrors and a wall: where each check lives

Section titled “Two mirrors and a wall: where each check lives”

A validation rule can live in three places, and each belongs to one for a reason.

The first is the browser, through the constraint attributes you just saw. The second is your Zod schema on the server. For the rules the browser can check, namely presence, length, format, and range, both places get the rule. “Amount is required” lives as required on the input and as a non-optional field in the schema; “valid email” as type="email" and z.email(); the 500-character cap as maxLength={500} and .max(500). These two layers are mirrors of each other.

The mirrors serve two jobs. The browser’s copy is for speed: instant feedback, no network. The schema’s copy is for correctness, because the browser’s copy can be skipped entirely. A stale tab running last week’s HTML, a submit that fires before your JavaScript loads, a script or curl hitting the action directly: none of those run the attributes. The constraint API is an optimization on top of a correct server, never a substitute for one.

The third place, the action body, differs in kind. Some rules can’t be checked in the browser because the browser doesn’t have the data. Is this invoice number already used in the org? Does this customer belong to this org? Is the org under its monthly invoice limit? Each requires reading server state the client has never seen, so these rules live only in the action body, after the parse. This third place is a wall: the client can’t see past it.

Browser constraint attributes
requiredtype="email"minmaxlengthpattern

before the network · free · skippable

Server schema Zod
non-optionalz.email().min().max().regex()

the boundary of correctness

Action body after the parse
uniquenessplan limitcustomer exists

needs server state · client can’t see it

Shape rules are mirrored on both sides; business rules live only behind the wall.

The duplication between zones 1 and 2 is the design, not a smell. The schema is the single source of truth for the shape of the data, and the constraint attributes are that shape projected into the browser so the user gets feedback the instant they make a mistake.

No tool in this stack generates the attributes from your schema, so keeping the two mirrors in sync is a manual, reviewed discipline, like keeping an input’s name matching its schema key. When you add .max(1000) to the note in the schema, you bump maxLength to match in the same change.

Sort each invoice rule into where it must be enforced. Drag each item into the bucket it belongs to, then press Check.

Constraint attribute + schema The browser checks it for UX; the schema enforces it for real
Schema only No attribute expresses it, but it's still a shape rule
Action body only Needs server state the client can't see
Amount must be present
Amount must be a positive number
Email is a valid email
Note is at most 500 characters
Invoice number is unique within the org
Customer belongs to this org
Org is under its monthly invoice limit
Amount has at most two decimal places

The last chip is where people get it wrong: Amount has at most two decimal places is schema only, not a mirror. It looks like a mirror because step="0.01" seems to enforce precision, but step is only a UX nudge: the browser flags 1.005 in its native check, yet a scripted request sends three decimals straight past it. Strict two-decimal precision is a guarantee, and a guarantee lives in the schema (a .multipleOf(0.01) refinement), never in an HTML attribute. The three action body only rules land there because the browser can’t see the org’s other invoices, its customer list, or its plan limit.

How constraint validation meets the React 19 submit

Section titled “How constraint validation meets the React 19 submit”

You wired <form action={formAction}> earlier, and the cheap layer slots into that submit for free: React’s form submit respects native validation. When the user clicks submit, React first lets the browser run constraint validation. If any field is invalid, the action never fires: the browser blocks the submission, pops the first invalid field’s native bubble, and moves focus to it; your action, your isPending, and your Result never run.

The browser’s check is one inserted step in the lifecycle you already know:

  1. The user clicks submit.
  2. The browser runs constraint validation. If any field is invalid, it stops here: report the error, focus the field, done. Nothing past this point runs.
  3. React serializes the named inputs into FormData.
  4. isPending flips to true.
  5. The action runs on the server.
  6. The Result comes back, the form re-renders.

The everyday “I left a field blank” never reaches step 3: no network, no pending state, no Result. This layer also survives with JavaScript off, since the browser checks required and pattern natively either way; the progressive-enhancement lesson returns to that.

Styling the invalid state with :user-invalid

Section titled “Styling the invalid state with :user-invalid”

The browser flags invalid fields but shows nothing until submit, so you’ll usually want to color an invalid field’s border red. CSS gives you pseudo-classes that read the browser’s validation state directly, and the choice between two of them matters.

Avoid :invalid. It matches a field’s validity from the first render, so a required field painted with :invalid turns red the instant the page loads, before the user has typed anything. That scolds someone for not yet filling out a form they just opened.

Reach for :user-invalid instead. It matches only after the user has engaged the field, whether they edited it and moved on or tried to submit. Same red border, but only once the user has had a chance to make a mistake.

The course styles through Tailwind, so you write these as variants that read from the DOM, not from a useState.

<input
name="email"
type="email"
required
className="border user-invalid:border-destructive aria-invalid:border-destructive"
/>

Note the pairing. user-invalid: covers the pre-submit case, where the browser caught a bad field before it left the page. aria-invalid: covers the post-submit case: the <FieldError> you built sets aria-invalid on the input when the server returns an error, and this variant gives that state the identical border.

The exercise below has two required inputs, one styled with invalid: and one with user-invalid:.

Type something in each field, then clear it. One field's border was red before you ever touched it: that's the one styled with the invalid: variant. The user-invalid: field stays calm until you've engaged it and left it (or tried to submit). Same red border, very different timing, and that timing is the whole point.

Preview LIVE

Replacing the native bubble with the design system

Section titled “Replacing the native bubble with the design system”

The browser’s native bubble, the gray “Please fill out this field” tooltip, is fine for a prototype but wrong for a designed UI: you can’t style it and it pops up wherever the browser decides. You want the cheap pre-submit check but your own message. Two approaches get you there, and only one is right.

<form action={formAction} noValidate>
<Input name="email" type="email" required />
<SubmitButton>Create invoice</SubmitButton>
</form>

This turns off every constraint check, not just the bubble. With noValidate the browser skips required, type, and pattern. The server’s Zod parse still catches everything, but every shape error now costs a full server roundtrip, the latency this lesson opened against. Reach for it only when the design system fully owns inline-error rendering and you’ve accepted that cost.

In practice you rarely need either move, because the design-system rendering takes over the bubble on its own. The chapter’s default: keep constraint validation on, let :user-invalid styling mark the bad field, and let the <FieldError> you already built render any message. A post-submit error comes back in the Result for <FieldError> to paint, including the business-rule failures from behind the wall that the browser could never run.

When attributes aren’t enough: setCustomValidity

Section titled “When attributes aren’t enough: setCustomValidity”

Some rules can’t be expressed as an attribute, because they span more than one field. An invoice’s due date must fall after its issue date, and you can’t hard-code a min, because the floor is whatever the other field currently holds. For these, JavaScript marks the field invalid by hand.

input.setCustomValidity('Due date must be after the issue date') flags the field with that message; the field then blocks submit and joins :user-invalid styling like any native check. Passing an empty string clears the flag. The idiomatic React call site reads the inputs through a ref and recomputes the flag whenever the field changes.

const dueDateRef = useRef<HTMLInputElement>(null);
const checkDueDate = () => {
const dueDate = dueDateRef.current;
const issueDate = dueDate?.form?.elements.namedItem('issueDate');
if (!dueDate || !(issueDate instanceof HTMLInputElement)) return;
dueDate.setCustomValidity(
dueDate.value < issueDate.value
? 'Due date must be after the issue date'
: '',
);
};

The input it drives:

<input ref={dueDateRef} name="dueDate" type="date" onBlur={checkDueDate} required />

The instanceof HTMLInputElement check narrows the namedItem lookup with no cast. Because type="date" values are YYYY-MM-DD strings, they compare chronologically under a plain string <.

To inspect why a field is invalid, every control exposes a ValidityState : after the call above, dueDate.validity.customError is true.

The boundary holds even here: a setCustomValidity check is still a mirror, so the same “due date after issue date” rule belongs in the schema as a .refine(). This is the case that feels most like real client logic, so it’s the one where you’ll be most tempted to stop at the client check. Don’t.

This is also not a place to call the server. Checking invoice-number uniqueness against the database is a different technique, a deliberate debounced fetch or a job for the form library in the next chapter. The constraint API stays client-only and synchronous.

Autocomplete and inputmode: the zero-cost wins

Section titled “Autocomplete and inputmode: the zero-cost wins”

These two attributes aren’t validation; they reject nothing. But leaving them off, as scaffolds do constantly, is a real cost, and filling them in is one of the cheapest upgrades a form can get.

autoComplete tells the browser and password manager what each field holds, so they offer the right autofill. Every input with a known data type should name its token:

  • Identity and contact: email, name (or the granular given-name / family-name), tel, bday.
  • Address: street-address, postal-code, country.
  • The security-relevant trio: new-password, current-password, one-time-code.

That last trio earns its keep. new-password triggers the password manager’s suggest-a-strong-password flow on signup; current-password offers the right credential on sign-in; one-time-code lets phones autofill an SMS code into a 2FA field. The auth forms you build later in the course depend on these tokens to work with the password manager instead of against it.

inputMode is the mobile companion: it tells a phone which soft keyboard to raise. An invoice amount wants decimal (a number pad with a decimal point), an integer field wants numeric. It doesn’t replace type: type carries the validation and semantics while inputMode only changes the on-screen keyboard, so set both. Set inputMode on numeric and contact fields, and autoComplete on any field the user has typed before.

This chapter has built forms by hand: <label>, shadcn’s <Input>, your <FieldError>, and the aria wiring. But shadcn also ships a set of form primitives. What do they add to a native <form action={formAction}>?

The set is <Form>, <FormField>, <FormItem>, <FormLabel>, <FormControl>, <FormDescription>, and <FormMessage>. They look like layout helpers, but they aren’t. <FormLabel>, <FormControl>, <FormDescription>, and <FormMessage> all call an internal useFormField() hook that reads React Hook Form ’s context, so used outside a <FormField> (itself a thin wrapper over React Hook Form’s Controller) they throw. They are React Hook Form components wearing layout clothes.

The one exception is <FormItem>, a vertical spacing wrapper, roughly a grid gap-2 div, that touches no React Hook Form context. It’s the only piece you can use without the library.

So in this chapter, where the form is native <form action> plus useActionState, the primitives buy you little. Keep the field cluster you already built; to match the design system’s exact field spacing, wrap it in <FormItem>.

const errorId = useId();
const messages = state?.ok === false ? state.error.fieldErrors?.email : undefined;
return (
<div className="grid gap-2">
<label htmlFor="email">Send invoice to</label>
<Input
id="email"
name="email"
type="email"
required
aria-invalid={messages != null}
aria-describedby={errorId}
/>
<FieldError id={errorId} messages={messages} />
</div>
);

What the earlier lesson built: correct, accessible, shippable as-is. The grid gap-2 is the hand-written field spacing.

The full shadcn form stack earns its weight the moment your form’s shape outgrows flat FormData: dynamic line-item arrays, multi-step wizards, or controlled third-party inputs whose values must be tracked as the user types. That’s the trigger for React Hook Form, and where the next chapter picks up. Until then, native <form action> plus your own field cluster is the lighter default.