Skip to content
youhoc
  • Pages
    • Home
    • Modern App Guidelines
    • Linux
      • Day 1: Linux Distributions & Navigation
      • Day 2: User Management
      • Day 3: File Permission & Ownership
      • Day 4: Package Management
      • Day 5: Services Management
    • Javascript
      • JS The Weird Part
        • Execution Context
        • Types & Operators
        • Objects & Functions
        • Error Handling & Strict Mode
        • Typescript, ES6, Tra
      • Modern JS
        • JS in the Browser
        • Data Storage JSON
        • Modern JS
        • Advanced Objects & Methods
        • Webpack & Babel
        • Async
      • jQuery
        • In-depth Analysis of jQuery
      • React-ready JS
        • Arrow Function
        • Template Literals
        • Logical AND, OR, Ternary, Nullish Operators
        • Destructuring & Rest Operator
        • Array Method
        • Immutability and Spread Operator
        • Promises, Async/Await, Callback
    • PHP
      • gruntJS
      • composer
      • MySQL
    • Docker
      • Container Basics
      • Container Networking
      • Container Image
      • Container Volume & Persistent Data
      • Dockerfile
      • Docker Compose
      • Docker Registry
    • Node.js
      • Installing & Exploring
      • Loading Modules
      • npm - Get Command Input
      • Web Server
        • Express Web Server
        • Template Engine & MVC
      • Authentication
      • 7. Databases
      • 8. Rest API
      • Errors
      • Sequelize
        • Sequelize Transactions: Đảm Bảo Tính Toàn Vẹn Dữ Liệu
        • 7 loại Data Types phổ biến Trong Sequelize
        • Phân Trang (Pagination) Trong Express.js Với Sequelize/MySQL
      • File Upload with Multer, Express.js
      • Hướng dẫn Cơ bản về Rest API
      • Server-Side Validation Với Express-Validator
      • Authentication Trong REST API Với JWT
      • Node-cron Simple to Complex Setup with PM2
      • HTMx Form: Gửi request, nhận response, và swap DOM
    • ReactJS
      • React from Andrew
        • Summary from Next
        • 1. Basics
        • 2. React Components
        • 3. Webpack
        • 4. Styling with SCSS
        • 5. React Router
        • 6. React Hook
      • Modern React From The Beginning
        • Intro to JSX
        • Vite Build Tools
        • Basic Component Creation
        • Component State
        • Props & Component Composition
        • useState with Inputs & Form Submission
        • useEffect, useRef & Local Storage
        • Async / Await and Http Request in React
        • React Router: Declarative Mode
        • ContextAPI
        • React Router: Framework Mode
          • File-routing & HTML Layouts
          • Server-side Data Query
          • Links & Navigation
          • Loaders
    • Typescript
      • Type User vs UserProp
    • Payload CMS
    • Authentication

HTMx Form: 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:
Người dùng trigger hành động: click, submit, keyup, scroll-to...
htmx chuẩn bị và gửi HTTP request đến server
Gom data từ form/input, thêm headers, gọi XMLHttpRequest đến server
Server xử lý, trả về HTML
Không phải JSON — server trả về một đoạn HTML thuần
htmx nhận response, kiểm tra status code
2xx → swap bình thường.
4xx/5xx → không swap (mặc định)
Nếu hợp lệ → swap HTML vào DOM
Chèn HTML vào đúng element theo hx-targethx-swap
Ở 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
There are no rows in this table
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ẽ:
Gom data cần thiết (từ form hoặc hx-include)
Gửi một HTTP POST request đến /save
Chờ server phản hồi
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
There are no rows in this table
Đâ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.detaile.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:
e.detail.xhr.status — HTTP status code (200, 404, 422, 500…)
e.detail.xhr.responseText — nội dung HTML server trả về (dạng string)
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ể:
Kiểm tra status code
Quyết định có cho phép swap không
Thay đổi nội dung sẽ được swap
e.detail lúc này có thêm hai thuộc tính quan trọng:
document.addEventListener('htmx:beforeSwap', function(e) {
console.log(e.detail.shouldSwap); // true/false — htmx có định swap không
console.log(e.detail.serverResponse); // HTML server trả về (string)
});
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:
document.addEventListener('htmx:beforeSwap', function(e) {
if (e.detail.xhr.status === 422) {
e.detail.shouldSwap = true; // ép htmx swap dù là 422
}
});
Server trả về (với status 422):
<form hx-post="/register" hx-target="#form-area" hx-swap="outerHTML" id="form-area">
<p style="color: red">Email này đã được sử dụng.</p>
<input name="email" type="email" />
<button>Đăng ký</button>
</form>
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
There are no rows in this table

Ví dụ đầy đủ — Form đăng ký

Gộp tất cả lại:
<form
hx-post="/api/register"
hx-target="#form-area"
hx-swap="outerHTML"
id="form-area"
>
<input name="email" type="email" placeholder="Email" />
<input name="password" type="password" placeholder="Mật khẩu" />
<button>Đăng ký</button>
<span id="spinner" style="display: none">Đang xử lý...</span>
</form>
// Hiện spinner khi bắt đầu gửi
document.addEventListener('htmx:beforeRequest', function(e) {
document.getElementById('spinner').style.display = 'inline';
});

// Ẩn spinner sau khi nhận response (dù thành công hay lỗi)
document.addEventListener('htmx:afterRequest', function(e) {
const spinner = document.getElementById('spinner');
if (spinner) spinner.style.display = 'none';
});

// Cho phép swap nếu server trả về 422 kèm HTML lỗi validation
document.addEventListener('htmx:beforeSwap', function(e) {
if (e.detail.xhr.status === 422) {
e.detail.shouldSwap = true;
}
});

// Nếu lỗi server (500+), hiện alert thay vì swap
document.addEventListener('htmx:responseError', function(e) {
if (e.detail.xhr.status >= 500) {
alert('Hệ thống đang có vấn đề. Vui lòng thử lại sau.');
}
});
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

htmx dùng XMLHttpRequest bên dưới để gửi request — đó là nguồn gốc của e.detail.xhr
htmx thông báo qua custom events — đó là lý do phải dùng addEventListener
e.detail.xhr.status là HTTP status code — cách bạn biết request thành công hay lỗi
htmx mặc định không swap 4xx/5xx — dùng htmx:beforeSwap với e.detail.shouldSwap = true để override
Chọn event theo mục đích: spinner dùng beforeRequest/afterRequest, validation dùng beforeSwap, init JS dùng afterSwap

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à để:
Trích xuất đúng cái nhãn msg ra một Object mới inlineMsg gọn gàng hơn.
Lấy tên các trường bị lỗi (errorFields) để ném vào trong câu thông báo Toast.
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:
Added the attribute to the form:
html
<form class="form grid gap-6" data-handle-errors action="/login" ...>
Updated the listener to check for it:
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;
Updated the typing clearer:
// 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>

Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.