HTMx Form: Request, Response, Swap
Gửi request, nhận response, và swap DOM
Bài này mình sẽ giải thích từng bước một, từ cái cơ bản nhất: htmx làm gì khi bạn click một cái button có hx-post, response về thì nó xử lý ra sao, và tại sao lại cần đến addEventListener, htmx:beforeSwap, hay e.detail.xhr.
Bức tranh tổng thể
Trước khi đi vào chi tiết, hãy hiểu luồng chung. Mỗi khi htmx gửi một request, nó đi qua các bước sau:
Ở mỗi bước trong luồng này, htmx phát ra một event để báo cho bạn biết đang ở giai đoạn nào. Đó chính là lý do tồn tại của các event như htmx:beforeRequest, htmx:afterRequest, htmx:beforeSwap, v.v.
Các sự kiện (events) trong vòng đời của một request
htmx phát ra các custom events trên DOM trong suốt quá trình request. Bạn dùng addEventListener để lắng nghe chúng. Đây là toàn bộ lifecycle theo thứ tự:
htmx:configRequest
Trước khi gửi — htmx đang chuẩn bị request
Thêm headers, modify params, cancel request
htmx:beforeRequest
Request sắp được gửi đi
Hiện loading spinner
htmx:afterRequest
Sau khi nhận response (dù thành công hay lỗi)
Ẩn spinner, log analytics, xử lý cả 2 trường hợp
htmx:beforeSwap
Trước khi htmx swap HTML vào DOM
Kiểm tra status, quyết định có swap không, swap custom
htmx:afterSwap
Sau khi swap xong
Init JS cho nội dung mới (charts, listeners...)
htmx:afterSettle
Sau khi DOM ổn định (CSS transitions xong)
Scroll, focus vào element mới
htmx:responseError
Status code 4xx hoặc 5xx
Hiện thông báo lỗi
Tại sao phải dùng addEventListener? htmx không expose API như htmx.onBeforeSwap = function(){}. Thay vào đó, nó dùng cơ chế sự kiện của trình duyệt. addEventListener là cách trình duyệt cung cấp để bạn "đăng ký" nghe một sự kiện. Giống như bạn đặt mua báo — mỗi sáng báo đến (event), bạn được thông báo.
Bước 1 — Gửi request
Giả sử bạn có đoạn HTML này:
Khi người dùng click “Lưu”, htmx sẽ:
Phía sau, htmx dùng XMLHttpRequest — một API có sẵn trong trình duyệt để gửi HTTP request mà không reload trang. htmx bọc nó lại cho bạn, bạn không cần viết fetch().
Bạn không cần biết XHR hoạt động thế nào để dùng htmx, nhưng biết nó tồn tại thì bạn sẽ hiểu tại sao sau này lại có e.detail.xhr.
Bước 2 — Nhận response
Server xử lý xong và trả về một đoạn HTML. Khác với REST API thông thường trả về JSON, htmx mong nhận HTML thuần.
Ví dụ server trả về:
htmx nhận đoạn này và chuẩn bị chèn vào #result.
Bước 3 — Status code và hành vi mặc định của htmx
Trước khi swap, htmx kiểm tra HTTP status code trong response:
Status code
Ý nghĩa
htmx làm gì mặc định
2xx (200, 201…)
Thành công
Swap HTML vào DOM
3xx
Redirect
Follow redirect
4xx (400, 422, 404…)
Lỗi từ phía client
Không swap, bỏ qua response
5xx (500, 503…)
Lỗi từ server
Không swap, bỏ qua response
Đây là điểm quan trọng: nếu server trả về 4xx hoặc 5xx, htmx nhận được HTML nhưng sẽ không chèn vào DOM. Response bị bỏ qua hoàn toàn. Đó là lý do bạn cần can thiệp thủ công trong những trường hợp này.
Bước 4 — Events và tại sao phải dùng addEventListener
htmx không cung cấp API kiểu htmx.onBeforeSwap = function() {}. Thay vào đó, nó thông báo bằng cách phát (dispatch) custom events lên DOM.
Cơ chế này là chuẩn của trình duyệt. addEventListener là cách bạn đăng ký “nghe” một event — giống như cách bạn nghe click hay submit, nhưng ở đây là các event do htmx tự phát ra.
Bước 5 — e.detail và e.detail.xhr là gì
Khi htmx phát một event, nó đính kèm thông tin vào e.detail. Đây là object chứa mọi thứ bạn cần biết về request đó.
e.detail.xhr là object XMLHttpRequest sau khi đã nhận xong response. Từ đó bạn đọc được:
Vì vậy, cách thông thường để kiểm tra kết quả:
Bước 6 — htmx:beforeSwap và cách can thiệp vào quyết định swap
Đây là event quan trọng nhất khi xử lý lỗi.
htmx:beforeSwap chạy ngay trước khi htmx chuẩn bị chèn HTML vào DOM. Ở thời điểm này, bạn có thể:
e.detail lúc này có thêm hai thuộc tính quan trọng:
Ví dụ thực tế: Server trả về 422 kèm HTML có thông báo lỗi validation. Mặc định htmx sẽ không swap. Bạn muốn hiện lỗi đó lên form:
Server trả về (với status 422):
Nhờ shouldSwap = true, htmx chèn form mới (có thông báo lỗi) vào đúng vị trí. Người dùng thấy lỗi ngay mà không cần reload.
Khi nào dùng event nào
Mục đích
Event nên dùng
Hiện/ẩn loading spinner
htmx:beforeRequest (hiện) và htmx:afterRequest (ẩn)
Kiểm tra kết quả, log analytics
htmx:afterRequest — dùng e.detail.successful
Hiện thông báo lỗi server (không cần swap)
htmx:responseError
Cho phép swap HTML lỗi từ server (4xx/5xx)
htmx:beforeSwap — set e.detail.shouldSwap = true
Init JS sau khi nội dung mới vào DOM
htmx:afterSwap
Thêm auth header vào mọi request
htmx:configRequest
Ví dụ đầy đủ — Form đăng ký
Gộp tất cả lại:
Khi đăng ký thành công, server trả về 200 với HTML trang “thành công” — htmx tự swap. Khi email đã tồn tại, server trả về 422 với form có thông báo lỗi — shouldSwap = true giúp htmx swap form đó vào. Khi server crash, htmx:responseError bắt được và hiện alert.
Tóm lại
Bonus: Phổ quát hóa nhận diện và trả phản hồi báo lỗi nhiều thành phần của form cùng 1 lúc
Sử dụng Object.entries
Object.entries() là một phương thức tích hợp sẵn rất phổ biến trong JavaScript. Hàm này biến một Object thông thường thành một mảng (Array) gồm các cặp [key, value].
Ví dụ, nếu chúng ta có Object:
Thì Object.entries(obj) sẽ trả về:
Lý do chúng ta dùng nó không phải là "overkill" (dùng dao mổ trâu giết gà) mà là vì trong JavaScript, bạn không thể dùng vòng lặp forEach hoặc map trực tiếp lên một Object. Object không có tính chất khả lặp (iterable) giống như Mảng (Array).
Hãy xem xét lý do cụ thể tại sao nó lại xuất hiện ở cả hai nơi:
1. Ở Front-end (login.ejs)
Từ máy chủ trả về, data.inlineMsg là một Object dạng: { email: "Lỗi email", ... }.
Để tự động tìm input dựa trên cái tên (key) và hiển thị thông báo lỗi (value), chúng ta bắt buộc phải lặp qua Object này.
Object.entries là cách ngắn gọn và hiện đại nhất để lấy cả chuỗi fieldName (key) và message (value) ra xài ngay lập tức. Bạn có thể viết cách khác như dùng Object.keys(), nhưng nó sẽ hơi rườm rà hơn một chút:
// Dùng Object.keys
Object.keys(data.inlineMsg).forEach(fieldName => {
const message = data.inlineMsg[fieldName]; // Phải thêm 1 bước lấy value
// ...
});
2. Ở Back-end (authController.js)
Ở đây, errors.mapped() của express-validator trả về một Object rất to chứa nhiều thông tin thừa:
{
email: { // đây là key name của input field
type: "field",
value: "abc",
msg: "Email không hợp lệ", // đây là value của báo lỗi input field đó
path: "email",
location: "body"
}
}
Mục đích của việc lặp ở backend là để:
Tương tự, bạn cũng có thể viết bằng Object.keys() nếu không thích Object.entries:
const mappedErrors = errors.mapped();
// thông báo lỗi trên từng input field
const inlineMsg = {};
// map của key input field
const errorFields = Object.keys(mappedErrors); // ['email', 'password']
// map value (thông điệp lỗi) của input field đó
errorFields.forEach(field => {
inlineMsg[field] = mappedErrors[field].msg; // Trích xuất .msg
});
// thông báo lỗi chung trên toast
const summary = `Có lỗi khi điền thông tin các field: ${errorFields.join(', ')}`;
Tóm lại: Việc dùng Object.entries là một best practice rất chuẩn mực (modern JS) để vừa lấy giá trị Key và Value ra từ một Object để làm thành vòng lặp. Nó không hề nặng nề hay overkill tới hiệu suất của ứng dụng. Bạn có muốn mình đổi lại thành Object.keys() ở backend cho code trông bớt lặp lại không?
So if I have multiple fields with name, I just reuse this? Should we check the form name and htmx response match?
Yes, exactly! That is the perfect question to ask. If we don’t restrict it, this script might accidentally try to parse errors for other HTMX requests on the page (like a button click or a different form).
To make it safe and reusable, I have added an HTML attribute called data-handle-errors.
Here is what I just updated in login.ejs:
html
<form class="form grid gap-6" data-handle-errors action="/login" ...>
javascript
// Only process if it's a form AND it explicitly opted into our generic error handling
if (form.tagName !== 'FORM' || !form.hasAttribute('data-handle-errors')) return;
// Only target inputs inside forms that opted into handle-errors
if (e.target.matches('form[data-handle-errors] input...')) {
How to reuse this in the future: If you build a registration form or a profile update form, you can simply add the data-handle-errors attribute to that new <form>. As long as the backend returns inlineMsg: { fieldName: 'error' }, this exact same script will automatically find the inputs and highlight them without writing a single line of new JavaScript!
(Eventually, you could move this entire <script> block out of login.ejs and into footer.ejs or a main JS file so it works globally across your whole app).
// front-end
<script>
// Handle inline validation errors generically for the login form (and extensible to others)
document.body.addEventListener('htmx:afterRequest', (e) => {
const xhr = e.detail.xhr;
if (xhr.status >= 400) {
const form = e.target;
// SECURITY/ROBUSTNESS CHECK:
// Only process if it's a form AND it explicitly opted into our generic error handling
if (form.tagName !== 'FORM' || !form.hasAttribute('data-handle-errors')) return;
// First, always clear existing error styling/messages from all inputs within this form
form.querySelectorAll('input, select, textarea').forEach(input => {
input.removeAttribute('aria-invalid');
const container = input.closest('.grid');
container?.querySelectorAll('.text-destructive').forEach(el => el.remove());
});
try {
const data = JSON.parse(xhr.responseText);
// Show field-specific inline messages if present
if (data.inlineMsg) {
let firstErrorInput = null;
Object.entries(data.inlineMsg).forEach(([fieldName, message]) => {
// Best Practice: Match input by its 'name' attribute which maps to backend validation keys
const input = form.querySelector(`[name="${fieldName}"]`);
if (input) {
// Highlight the input using aria-invalid
input.setAttribute('aria-invalid', 'true');
// Create and append the error message
const errEl = document.createElement('p');
errEl.className = 'text-destructive text-xs mt-1';
errEl.textContent = message;
// Handle specific UI wrappers (like relative wrappers for icons)
if (input.parentElement.classList.contains('relative')) {
input.parentElement.insertAdjacentElement('afterend', errEl);
} else {
input.insertAdjacentElement('afterend', errEl);
}
if (!firstErrorInput) {
firstErrorInput = input;
}
}
});
// Focus the first invalid field automatically for better UX
if (firstErrorInput) {
firstErrorInput.focus();
}
}
// Global toast handling in footer.ejs covers data.toast automatically
// ignore error
} catch(e) {}
}
});
// Clear error styling when typing in any field dynamically
// Only target inputs inside forms that opted into handle-errors
document.addEventListener('input', (e) => {
if (e.target.matches('form[data-handle-errors] input, form[data-handle-errors] select, form[data-handle-errors] textarea')) {
e.target.removeAttribute('aria-invalid');
const container = e.target.closest('.grid');
if (container) {
container.querySelectorAll('.text-destructive').forEach(el => el.remove());
}
}
});
</script>
