HTMX formdata extension: make formdata-aware components work with htmx
Many modern UI components, including Web Components, custom inputs, and rating widgets, integrate with form submission by listening for the browser’s formdata event and appending their values to the outgoing FormData object; however, because htmx does not always construct a FormData(form) instance for every request, that event may never fire, preventing those components from contributing their data—an issue noted in community discussions. This guide explains how to build and use a lightweight formdata extension that explicitly constructs FormData(form) to trigger the browser’s formdata event, synchronizes any changes made by formdata listeners back into htmx’s request parameters through htmx:configRequest, and optionally supports file uploads by working with hx-encoding="multipart/form-data".
What the browser does (and what htmx might skip)
The browser path
When a form is submitted normally, the browser builds an “entry list” and fires formdata. That also happens when you call new FormData(form).
Listeners can do things like:
The htmx path
htmx gathers parameters for requests and lets you mutate them with htmx:configRequest. But if no FormData(form) is constructed, formdata listeners never run.
Install & enable the extension
Include the extension script
Save this as ext/formdata.js (or serve it from your assets pipeline):
<script src="/js/htmx.min.js"></script>
<script src="/js/ext/formdata.js" defer></script>
Enable it with hx-ext (on a form, a container, or body).
<body hx-ext="formdata">
...
</body>
The formdata extension (reference implementation)
This implementation runs during htmx:configRequest, constructs a FormData(form) to trigger formdata, then syncs entries back into evt.detail.parameters (which htmx will send).
// /js/ext/formdata.js
(function () {
function closestForm(elt) {
if (!elt) return null;
if (elt.tagName === "FORM") return elt;
return elt.closest ? elt.closest("form") : null;
}
// Convert htmx parameter object -> a FormData (for easier diff/merge)
function paramsToFormData(params) {
const fd = new FormData();
Object.entries(params || {}).forEach(([k, v]) => {
if (Array.isArray(v)) v.forEach((item) => fd.append(k, item));
else if (v != null) fd.append(k, v);
});
return fd;
}
// Convert FormData -> plain object suitable for evt.detail.parameters
function formDataToParams(fd) {
const out = Object.create(null);
fd.forEach((value, key) => {
if (Object.prototype.hasOwnProperty.call(out, key)) {
out[key] = Array.isArray(out[key]) ? out[key].concat([value]) : [out[key], value];
} else {
out[key] = value;
}
});
return out;
}
htmx.defineExtension("formdata", {
onEvent: function (name, evt) {
if (name !== "htmx:configRequest") return;
const elt = evt.detail.elt;
const form = closestForm(elt);
// No form context? Nothing to do.
if (!form) return;
// 1) Start with what htmx already collected
const baseParams = evt.detail.parameters || {};
const baseFD = paramsToFormData(baseParams);
// 2) Force browser FormData construction from the form.
// This triggers the `formdata` event on the form. :contentReference[oaicite:8]{index=8}
const nativeFD = new FormData(form);
// 3) Merge:
// - Keep htmx-collected params (baseFD)
// - Apply any additions/overrides made by `formdata` listeners (nativeFD)
//
// Rule of thumb: nativeFD "wins" for keys it provides.
// (You can flip this if your app expects the opposite.)
const merged = new FormData();
// Add base first
baseFD.forEach((v, k) => merged.append(k, v));
// Overlay native values (remove existing key then add native entries)
const seenKeys = new Set();
nativeFD.forEach((v, k) => {
if (!seenKeys.has(k)) {
// delete existing key values from merged
// FormData has no delete-all-by-iteration, so we rebuild:
const rebuilt = new FormData();
merged.forEach((mv, mk) => {
if (mk !== k) rebuilt.append(mk, mv);
});
// swap
merged.forEach(() => {}); // no-op, keep scope happy
// assign rebuilt back by copying
merged._tmp = rebuilt; // not used further; just to keep mental model
// We'll just use rebuilt as the new merged for this key pass:
merged = rebuilt; // eslint-disable-line no-func-assign
seenKeys.add(k);
}
merged.append(k, v);
});
// 4) Put back into htmx parameters object
evt.detail.parameters = formDataToParams(merged);
// Optional: if you want to debug, uncomment:
// console.debug("[formdata ext] parameters:", evt.detail.parameters);
},
});
})();
A practical note about the merge logic
There isn’t one “right” merge rule. Common options:
Native wins (shown): formdata listeners can override values.
Base wins: htmx/DOM values override anything added by listeners.
Add-only: let listeners only add keys that don’t already exist.
Pick the rule that matches how your custom elements behave.
Add a formdata listener (example)
Here’s a “computed field” pattern: take two inputs, submit a derived value.
<form hx-post="/checkout" hx-target="#out" hx-ext="formdata">
<input name="qty" type="number" value="2">
<input name="price" type="number" value="9.99">
<button>Submit</button>
</form>
<div id="out"></div>
<script>
document.addEventListener("formdata", (e) => {
const fd = e.formData;
const qty = Number(fd.get("qty") || 0);
const price = Number(fd.get("price") || 0);
fd.set("total", String(qty * price));
});
</script>
Because the extension constructs new FormData(form), the event fires and total gets injected.
File uploads (when you should not fake it)
If you’re uploading files, don’t try to jam File objects into evt.detail.parameters and hope everything works. Use:
<form hx-post="/upload" hx-encoding="multipart/form-data">
<input type="file" name="f">
<button>Upload</button>
</form>
htmx explicitly documents that hx-encoding="multipart/form-data" makes it use FormData for the request body.
How the extension fits in: Even with multipart, you may still want formdata listeners to run before the request goes out (e.g., to add metadata fields). In that case, the extension is still useful, but you should be careful that your merge logic doesn’t drop file entries.
Server-side handlers (quick, real endpoints)
Node/Express (form posts)
npm init -y
npm i express multer
// server.js
import express from "express";
import multer from "multer";
const app = express();
const upload = multer();
app.use(express.urlencoded({ extended: true })); // for x-www-form-urlencoded
app.post("/checkout", (req, res) => {
// req.body will include "total" if extension + listener worked
res.send(`<pre>${escapeHtml(JSON.stringify(req.body, null, 2))}</pre>`);
});
app.post("/upload", upload.single("f"), (req, res) => {
res.send(`<pre>${escapeHtml(JSON.stringify({
fields: req.body,
file: req.file ? { originalname: req.file.originalname, size: req.file.size } : null
}, null, 2))}</pre>`);
});
app.listen(3000, () => console.log("http://localhost:3000"));
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[c]));
}
Run:
node server.js
Quick smoke tests with curl
URL-encoded:
curl -s -X POST http://localhost:3000/checkout \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "qty=2&price=9.99&total=19.98"
Multipart:
curl -s -X POST http://localhost:3000/upload \
-F "note=hello" \
-F "f=@./somefile.bin"
Debugging checklist
Confirm the extension is enabled
☐ Ensure the script is loaded
☐ Ensure hx-ext="formdata" is on the form/ancestor/body
Confirm formdata is supported in your target browsers
Modern browsers support it broadly; IE does not. (If you must support IE, you’ll need a different hook.)
Confirm your listener is attached correctly
formdata does not bubble (per MDN), so attach to the form itself or document with capture if needed.
Example (capture):
document.addEventListener("formdata", handler, true);
Confirm the timing
htmx:configRequest happens after htmx collects parameters and before the request is sent. It’s the right place to mutate evt.detail.parameters.
When to use this extension vs built-in htmx features
Use the formdata extension when you rely on Web Components or custom controls that contribute values through the browser’s formdata event, or when you want a single, reusable solution instead of attaching htmx:configRequest handlers throughout your codebase. On the other hand, prefer built-in htmx mechanisms when you only need to add extra values (using hx-vals or hx-include), when you’re sending JSON payloads (using json-enc or community form-json extensions, which serve a different purpose), or when handling file uploads, where hx-encoding="multipart/form-data" is the appropriate choice.
Production hardening ideas
If you’re going to rely on this heavily, consider adding:
In component-driven interfaces, it’s easy to assume that form submission is a simple, solved problem. But once you begin mixing htmx-driven requests with Web Components, computed fields, or custom inputs that rely on the browser’s formdata event, subtle gaps can appear. The formdata extension closes that gap in a clean and reusable way by deliberately constructing a FormData(form) instance, triggering the browser’s native lifecycle, and synchronizing the results back into htmx’s request pipeline.
By integrating this extension, you align htmx with the browser’s built-in form mechanics instead of working around them. That means custom controls behave as intended, computed values are reliably injected, and file uploads can coexist with enriched metadata. Just as importantly, you gain a predictable interception point—htmx:configRequest—where request parameters can be merged, validated, or transformed before leaving the client.
Used thoughtfully, this approach keeps your architecture declarative and component-friendly while preserving htmx’s lightweight, progressive-enhancement philosophy. Whether you’re building advanced UI widgets, layering in calculated fields, or standardizing submission behavior across a design system, the formdata extension provides a focused, production-ready bridge between native browser capabilities and htmx’s request model.
I noticed you mentioned the limitations of htmx in building form data, and that's a pretty key point The idea behind this extension seems very friendly for complex form scenarios I wanted to ask in what context you started working on this optimization