htmx: Build Interactive Web Apps with HTML · Beyond the Basics

htmx and JavaScript Together

Use hx-on for small behaviours, react to htmx events, initialise widgets in swapped content, and pair htmx with Alpine.js.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A contact card list where each new card (loaded by htmx) gets a "copy email" button initialised by JavaScript, and a small client-side toggle that needs no server.

The idea

htmx handles talking to the server; some behaviour is purely client-side. Use the lightest tool:

  • Plain HTML first: <details>, <dialog>, popover.
  • hx-on:<event> for one-liners (hx-on:click, hx-on::after-request).
  • htmx.onLoad(fn) (the htmx:load event) to initialise widgets in any content htmx adds, including the initial page. Initialising only on DOMContentLoaded misses swapped content.
  • Alpine.js for small reactive state in the markup (x-data, x-show); it works well alongside htmx.
  • htmx.process(el) when your own code inserts HTML with hx- attributes.

The code

Initialise widgets in swapped contentTry it yourself
<div id="cards"></div>
<button hx-get="/cards" hx-target="#cards" hx-swap="beforeend">Load contacts</button>
<details><summary>Help (plain HTML, no request)</summary><p>Click "Copy" to copy an email address.</p></details>

<script>
  // Runs for the initial page and for every piece of content htmx swaps in.
  htmx.onLoad((root) => {
    root.querySelectorAll("[data-copy]").forEach((btn) => {
      if (btn.dataset.ready) return;
      btn.dataset.ready = "1";
      btn.addEventListener("click", () => { btn.textContent = "Copied " + btn.dataset.copy; });
    });
  });
</script>

<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
  let n = 0;
  const people = ["ada", "grace", "alan", "katherine"];
  server.get("/cards", () => {
    const name = people[n++ % people.length];
    return `<p>${name}@example.com <button data-copy="${name}@example.com">Copy</button></p>`;
  });
</script>

With Alpine.js, client-only state stays in the markup:

<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
<div x-data="{ open: false }">
  <button @click="open = !open">Filters</button>
  <form x-show="open" hx-get="/contacts" hx-target="#rows">...</form>
</div>

Common mistakes

  • Initialising widgets on DOMContentLoaded only: content added later by htmx stays dead. Use htmx.onLoad.
  • Binding the same listener twice when content is processed again; mark initialised elements.
  • Using hx-on with camelCase event names: HTML attribute names are lower-case, so use kebab-case events.
  • Making a server round trip for purely visual state (open/closed menus).

Key points

  • Use HTML, then hx-on, then Alpine or small scripts for client behaviour.
  • htmx.onLoad initialises widgets in all new content.
  • htmx.process activates HTML your own code inserts.

Further reading: hx-on · htmx JavaScript API · htmx docs: scripting

Practice questions

Why should widget initialisation use htmx.onLoad instead of DOMContentLoaded?

Answer

DOMContentLoaded runs once; htmx.onLoad runs for the initial page and every piece of content htmx swaps in later.

When do you need htmx.process()?

Answer

When your own JavaScript inserts HTML containing hx- attributes; htmx only processes content it swaps itself.

Why use kebab-case event names with hx-on?

Answer

HTML attribute names are case-insensitive and lower-cased, so hx-on:myEvent would listen for 'myevent'.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?