HTMX formdata extension: make formdata-aware components work with htmx

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:

  • Add computed values
  • Add values from custom elements
  • Normalize fields before submission

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) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#039;" }[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:

  • Opt-in attribute: only run when data-formdata="true" is present
  • Safer merge strategy (add-only, or key allowlist)
  • Multi-form support for elements using the form="id" attribute
  • File-aware path: if a File is present, ensure the request uses multipart/form-data (or require hx-encoding explicitly)

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

Like
Reply

To view or add a comment, sign in

More articles by Christopher Adamson

  • Accessible Loading States in HTMX with aria-busy

    One important note up front: current htmx documentation does not list an official extension literally named aria-busy…

  • The htmx response-targets Extension

    The response-targets extension solves a very practical problem in htmx: routing different HTTP responses to different…

  • Getting Started with the htmx preload Extension

    The preload extension in htmx is designed to make navigation and fragment loading feel faster by fetching content…

  • Debouncing Requests in htmx

    Modern interactive interfaces often rely on continuous user input, whether it’s searching, filtering, validating, or…

  • Caching Strategies in htmx

    When people talk about “the htmx caching extension,” they usually mean one of three different things: preload, which…

  • Htmx CSRF Extension (a.k.a. csrf-token)

    In plain htmx setups, CSRF protection is typically handled by adding a header globally with hx-headers or by relying on…

    1 Comment
  • Htmx ajax-header Extension

    The ajax-header extension is small but addresses a common integration issue: some servers, middleware, or legacy…

  • Auto-Submitting Forms with the htmx Auto-Submit Extension

    Web applications increasingly favor responsive, event-driven interfaces where user interactions immediately trigger…

  • Alpine-Swap Deep Dive

    alpine-swap is an Alpine.js plugin that recreates htmx-style swapping behavior—such as targeting specific elements…

  • hx-ws → ws (htmx WebSockets extension)

    Quick orientation: In older htmx versions, WebSockets were wired up with the experimental hx-ws attribute. In current…

    1 Comment

Explore content categories