jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 FormData từ 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ínhKiểm tra gìVí dụ
requiredGiá trị không rỗng<input required>
typeĐịnh dạng theo loại inputemail, url, number, date
patternKhớp regexpattern="[a-zA-Z0-9_]+"
minlength / maxlengthĐộ dài chuỗiminlength="8"
min / max / stepGiới hạn số/ngàymin="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 ValidityState read-only với các cờ boolean
  • chuỗi human-readable (rỗng khi hợp lệ)

Và bốn method:

MethodMụ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
willValidateElement 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: truevalid: 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.

  1. Mỗi lần validate, reset custom validity trên cả hai field
  2. So sánh giá trị; gọi setCustomValidity trên field lỗi
  3. 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ẫySửa
Validate mỗi keystrokeDebounce; 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ỗiThô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

SelectorKhi nào khớp
:invalidBất cứ lúc nào constraint fail — kể cả required rỗng chưa chạm
:user-invalidSau 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ùng requestSubmit() hoặc tự gọi checkValidity() trước.

Match chuỗi validationMessage Message khác nhau theo browser và locale. Dùng cờ ValidityState.

UX validation kép Đừng hiện bubble native (reportValidity) lỗi inline cùng lúc. Chọn một với novalidate.

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:none hoặc hidden vẫn có thể fail checkValidity(). Bỏ required hoặ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

  1. Khai báo trong HTML được gìrequired, type, pattern, minlength
  2. Dùng cờ ValidityState, không phải chuỗi message, cho copy an toàn i18n
  3. setCustomValidity("") xoá; không rỗng set customError
  4. Validate blur → live sau lỗi đầu → kiểm tra đầy đủ khi submit
  5. Gắn aria-invalid, aria-describedby, focus lỗi đầu, aria-live
  6. :user-invalid hơn :invalid để tránh field đỏ lần render đầu
  7. 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.