Forms & the Constraint Validation API — Native Validation Done Right
Native HTML form validation deep-dive: ValidityState, setCustomValidity, async/cross-field patterns, ARIA errors, FormData — and why the server always wins.
Tại sao validation native vẫn quan trọng
Mọi luồng đăng ký, form checkout, và panel cài đặt đều phải validate input của user. Team thường chọn React Hook Form, Zod, hoặc Yup trước — và chúng rất tốt cho app phức tạp. Nhưng browser đã có sẵn Constraint Validation API xử lý 80% việc nhàm chán: field bắt buộc, định dạng email, độ dài min/max, pattern, và lỗi tuỳ chỉnh.
Hiểu tầng native giúp validation dựa thư viện **tốt hơn: bạn biết browser đã enforce gì, :user-invalid cho bạn miễn phí gì, và cách gắn thông báo lỗi accessible mà không phát minh lại bánh xe.
Quy tắc progressive enhancement: validation phía client là UX, không phải bảo mật. Server luôn phải validate lại. Không bao giờ tin
FormDatatừ mạng.
Thuộc tính ràng buộc HTML
Browser đánh giá ràng buộc khai báo trên <input>, <select>, và <textarea>. Bạn không viết regex cho “có phải email không?” — bạn đặt type="email".
| Thuộc tính | Kiểm tra gì | Ví dụ |
|---|---|---|
required | Giá trị không rỗng | <input required> |
type | Định dạng theo loại input | email, url, number, date |
pattern | Khớp regex | pattern="[a-zA-Z0-9_]+" |
minlength / maxlength | Độ dài chuỗi | minlength="8" |
min / max / step | Giới hạn số/ngày | min="0" max="100" step="5" |
<form>
<input name="username" required minlength="3" maxlength="20"
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, underscores only" />
<input name="email" type="email" required autocomplete="email" />
<input name="age" type="number" min="13" max="120" required />
<input name="password" type="password" required minlength="8"
autocomplete="new-password" />
</form>
Thuộc tính title trên field pattern trở thành validationMessage mặc định ở một số browser — nhưng bạn nên ghi đè trong JS để copy nhất quán.
novalidate — khi bạn lái
Thêm novalidate vào <form> khi bạn muốn thời điểm và thông báo tuỳ chỉnh nhưng vẫn dùng Constraint Validation API theo code:
<!-- Browser won't show native popup; you call reportValidity() yourself -->
<!-- {Browser không hiện popup native; bạn tự gọi reportValidity()} -->
<form id="signup" novalidate>...</form>
Không có novalidate, lần submit fail đầu tiên hiện bubble built-in của browser — khó style, không nhất quán giữa engine, và kém cho screen reader.
Constraint Validation API
Mọi form control hỗ trợ validation expose hai thuộc tính quan trọng:
- một object
ValidityStateread-only với các cờ boolean - chuỗi human-readable (rỗng khi hợp lệ)
Và bốn method:
| Method | Mục đích |
|---|---|
checkValidity() | Trả true/false; fire event invalid khi fail |
reportValidity() | Giống checkValidity() + hiện UI native (nếu không novalidate) |
setCustomValidity(msg) | Đặt hoặc xoá lỗi tuỳ chỉnh |
willValidate | Element có tham gia constraint validation không |
Các cờ ValidityState
const input = document.querySelector("#email");
const v = input.validity;
console.log({
valueMissing: v.valueMissing, // required + empty {required + rỗng}
typeMismatch: v.typeMismatch, // type="email" but not an email {type email nhưng không phải email}
patternMismatch: v.patternMismatch,
tooShort: v.tooShort,
tooLong: v.tooLong,
rangeUnderflow: v.rangeUnderflow, // below min {dưới min}
rangeOverflow: v.rangeOverflow,
stepMismatch: v.stepMismatch,
badInput: v.badInput, // unparseable number/date {số/ngày không parse được}
customError: v.customError, // setCustomValidity() was called {setCustomValidity() đã gọi}
valid: v.valid, // true only when ALL flags are false {true chỉ khi MỌI cờ false}
});
Dùng cờ — không match chuỗi validationMessage — để chọn copy hiển thị:
function messageFor(input) {
const v = input.validity;
if (v.valueMissing) return "This field is required.";
if (v.typeMismatch && input.type === "email") return "Enter a valid email.";
if (v.tooShort) return `At least ${input.minLength} characters.`;
if (v.customError) return input.validationMessage;
return "";
}
setCustomValidity — lối thoát
Attribute HTML không diễn tả được “password phải khớp” hay “username đã bị lấy”. setCustomValidity() lấp khoảng trống đó:
// Clear custom error first — stale messages persist otherwise
// {Xoá lỗi custom trước — message cũ sẽ tồn tại nếu không}
confirmInput.setCustomValidity("");
if (passwordInput.value !== confirmInput.value) {
confirmInput.setCustomValidity("Passwords do not match.");
}
Quy tắc quan trọng: Chuỗi không rỗng đặt customError: true và valid: false.
Demo trực tiếp
Demo dưới là form đăng ký đầy đủ: constraint native, khớp password cross-field, kiểm tra username async giả lập, timing blur/submit, lỗi ARIA inline, inspector ValidityState live, và preview FormData.
Mở demo đầy đủ:
Validation cross-field
Xác nhận password là ví dụ kinh điển. Không attribute HTML nào so sánh giá trị hai field.
- Mỗi lần validate, reset custom validity trên cả hai field
- So sánh giá trị; gọi
setCustomValiditytrên field lỗi - Chạy lại
checkValidity()hoặc logic hiển thị của bạn
function syncPasswordMatch() {
const pw = passwordInput.value;
const confirm = confirmInput.value;
passwordInput.setCustomValidity("");
confirmInput.setCustomValidity("");
if (confirm && pw !== confirm) {
confirmInput.setCustomValidity("Passwords do not match.");
}
}
passwordInput.addEventListener("input", () => {
if (confirmInput.dataset.liveAfterError === "true") syncPasswordMatch();
});
confirmInput.addEventListener("blur", syncPasswordMatch);
Validate field phụ thuộc (confirm), không chỉ nguồn. User tab từ password → confirm; lỗi nên hiện trên confirm khi họ rời field.
Async validation làm đúng
Kiểm tra username, email trùng, tra cứu mã VAT — cần round-trip server. Lỗi phổ biến:
| Cạm bẫy | Sửa |
|---|---|
| Validate mỗi keystroke | Debounce; validate blur trước |
| Race condition (response chậm ghi đè nhanh) | Token abort / tăng request ID |
| Chặn submit khi “đang kiểm tra…” | Disable submit HOẶC spinner; kiểm tra lại khi submit |
Dùng alert() cho lỗi | Thông báo inline + aria-live |
let abortToken = null;
async function checkUsername(name) {
const token = { aborted: false };
abortToken = token;
statusEl.textContent = "Checking…";
submitBtn.disabled = true;
await new Promise((r) => setTimeout(r, 600)); // simulated fetch {fetch giả lập}
if (token.aborted) return;
submitBtn.disabled = false;
usernameInput.setCustomValidity("");
if (takenSet.has(name.toLowerCase())) {
usernameInput.setCustomValidity("Username is already taken.");
statusEl.textContent = "✗ Taken";
} else {
statusEl.textContent = "✓ Available";
}
showError(usernameInput);
}
Khi submit, luôn await async check đang chờ trước khi gọi form.checkValidity(). User có thể blur username rồi ngay lập tức Enter.
Khi nào validate — timing cảm giác đúng
Validate mỗi keypress ngay từ load trang cảm giác hostile. Pattern team senior dùng:
Phase 1 — pristine: no errors shown while user types first time
Phase 2 — touched: validate on blur (field lost focus)
Phase 3 — live: after first error, re-validate on input (error clears as they fix)
Phase 4 — submit: validate ALL fields; focus first error
const state = { touched: false, liveAfterError: false };
input.addEventListener("blur", () => {
state.touched = true;
validate(input);
});
input.addEventListener("input", () => {
if (state.liveAfterError) validate(input);
});
form.addEventListener("submit", (e) => {
e.preventDefault();
state.liveAfterError = true;
if (!form.checkValidity()) {
focusFirstInvalid(form);
return;
}
// proceed {tiếp tục}
});
Map gọn với :user-invalid trong CSS — pseudo-class chỉ áp dụng sau khi user tương tác, nên field required rỗng không đỏ ngay lần render đầu.
CSS: :invalid vs :user-invalid
| Selector | Khi nào khớp |
|---|---|
:invalid | Bất cứ lúc nào constraint fail — kể cả required rỗng chưa chạm |
:user-invalid | Sau tương tác user (focus + blur, hoặc thử submit) |
/* Don't do this — empty required fields flash red on load */
/* {Đừng làm — field required rỗng đỏ ngay khi load} */
input:invalid { border-color: red; }
/* Better — only show error styling after interaction */
/* {Tốt hơn — chỉ style lỗi sau tương tác} */
input:user-invalid {
border-color: var(--color-error);
}
input:user-valid:not(:placeholder-shown) {
border-color: var(--color-success);
}
:user-valid là đối trọng tương ứng. Hỗ trợ tốt trên browser hiện đại; fallback class .is-invalid từ JS nếu cần.
Thông báo lỗi accessible
Popup validation native không đủ accessible cho UX production. Gắn lỗi rõ ràng:
aria-invalid + aria-describedby
<input
id="email"
type="email"
required
aria-describedby="email-error"
aria-invalid="false"
/>
<p id="email-error" role="alert"></p>
function showError(input, message) {
const errorEl = document.getElementById(input.getAttribute("aria-describedby"));
errorEl.textContent = message;
input.setAttribute("aria-invalid", message ? "true" : "false");
}
role="alert" trên element lỗi announce thay đổi ngay. Cho tóm tắt cấp form (“3 lỗi”), dùng aria-live="polite" trên vùng status.
Sau submit fail, chuyển focus tới field invalid đầu tiên và announce qua vùng aria-live. Kết hợp màu với chữ hiển thị — WCAG yêu cầu hơn viền đỏ.
FormData, serialize & submit
Building the payload
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!form.checkValidity()) return;
const fd = new FormData(form);
// Object for JSON APIs {Object cho JSON API}
const body = Object.fromEntries(fd.entries());
// Or send as multipart (file uploads) {Hoặc gửi multipart (upload file)}
await fetch("/api/signup", { method: "POST", body: fd });
// Or URL-encoded {Hoặc URL-encoded}
await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd).toString(),
});
});
FormData chỉ gồm control thành công (có name + không disabled + trong form). Checkbox không tick và radio không chọn bị bỏ qua — xử lý default phía server.
requestSubmit() vs submit()
// Fires submit event + constraint validation
// {Fire event submit + constraint validation}
form.requestSubmit();
// Bypasses validation entirely — avoid for user actions
// {Bỏ qua validation hoàn toàn — tránh cho hành động user}
form.submit();
Dùng requestSubmit() khi trigger submit có validate theo code (vd bước cuối wizard).
Progressive enhancement & validation server
Validation client cho phản hồi tức thì. Không cho bảo mật:
Browser ──POST──▶ Server
│
├─ Re-validate ALL fields (never trust client)
├─ Rate-limit async checks (username lookup)
├─ Return field-level errors (422 + JSON body)
└─ Log suspicious bypass attempts
Khi 422, map lỗi field server qua setCustomValidity và focus lại lỗi đầu. Không JS, lỗi HTML server render vẫn hoạt động.
Lỗi thường gặp
Quên xoá
setCustomValidity("")Một khi set, lỗi custom dính đến khi xoá — kể cả user đã sửa giá trị.
Gọi
form.submit()mong có validation DùngrequestSubmit()hoặc tự gọicheckValidity()trước.
Match chuỗi
validationMessageMessage khác nhau theo browser và locale. Dùng cờValidityState.
UX validation kép Đừng hiện bubble native (
reportValidity) và lỗi inline cùng lúc. Chọn một vớinovalidate.
Disable submit là chỉ báo duy nhất Nút disabled không focus được — user không biết vì sao bị chặn. Hiện lỗi inline thay vì.
Validate field ẩn quá gắt Field
display:nonehoặchiddenvẫn có thể failcheckValidity(). Bỏrequiredhoặc disable khi ẩn.
Thư viện vs native
Ở native cho field chuẩn, zero bundle, markup thân thiện SSR. Dùng React Hook Form + Zod hoặc TanStack Form cho mảng field lồng, schema client/server chung, hoặc tích hợp framework sâu. Thư viện compose với — không thay — API platform.
Điểm chính
- Khai báo trong HTML được gì —
required,type,pattern,minlength - Dùng cờ
ValidityState, không phải chuỗi message, cho copy an toàn i18n setCustomValidity("")xoá; không rỗng setcustomError- Validate blur → live sau lỗi đầu → kiểm tra đầy đủ khi submit
- Gắn
aria-invalid,aria-describedby, focus lỗi đầu,aria-live :user-invalidhơn:invalidđể tránh field đỏ lần render đầu- Server luôn validate — client là UX, không phải cổng bảo vệ
Validation form native không lỗi thời — nó là tầng platform abstraction của bạn nên tôn trọng. Nắm một lần; mỗi lần đổi framework rẻ hơn.