5 min read

htmx 4.0 Migration: The Breaking Changes That Actually Bit Me

HTML and JavaScript code open in a dark code editor

htmx 4.0 shipped this week, and the reaction in my feed was split between "finally" and "wait, htmx has a version number now?" Fair. The library spent years parked at 2.x, and this is its first major version bump since 2024. It is a real one: the whole request layer was rewritten from XMLHttpRequest to fetch().

I have a Django side project that has been running htmx since 2021, back when it was still on 1.x. A little ticket tracker, nothing precious. So on Friday night I did what you're supposed to do and ran their upgrade checker against it before touching anything. Four seconds later it had flagged eleven spots in my templates. Most were trivial. Two would have cost me a whole evening of confused debugging if I'd just swapped in the new script tag and hoped.

Here is the honest version of that migration, in the order it actually bit me.

Run the checker first, seriously

htmx 4 ships with a CLI that scans your templates and JavaScript for 2.x patterns that need updating. It knows the removed attributes, the old event names, the inheritance patterns. Output comes back as clickable file:line lines.

npx [email protected] upgrade-check -- ./templates

# it scans .html, .php, .js, .ts, .jinja, .erb, .hbs and friends by default,
# but you can teach it more:
npx [email protected] upgrade-check --ext .vue ./src

The -- before the path matters. I missed it on my first run and the CLI just stared back at me.

Attribute inheritance is now opt-in

This was my biggest one and the docs agree it's most people's. In htmx 2, attributes like hx-confirm and hx-target silently cascaded down from parent elements, CSS-style. Powerful, but also the reason I once spent twenty minutes working out why a delete button kept asking for confirmation when I never wrote that.

In 4.0, children only inherit if you say so with an :inherited suffix:

<!-- htmx 2: both buttons inherited hx-confirm. Maybe. Depends on nesting. -->
<!-- htmx 4: nothing inherits unless you ask -->
<div hx-confirm:inherited="Delete this ticket?">
  <button hx-post="/tickets/42">Delete</button>
  <button hx-post="/tickets/42/archive">Archive</button>
</div>

If you genuinely miss the old behavior, one line brings it back: htmx.config.implicitInheritance = true. I recommend against it. Explicit is annoying for a week and then it's just... correct. My checker hits were all places where a parent hx-target had been quietly leaking into buttons that never should have had it.

Event names got reorganized

The camelCase events are gone. Everything follows a htmx:phase:action shape now, which reads better once you stop mourning the old ones:

htmx:beforeRequest → htmx:before:request
htmx:afterRequest  → htmx:after:request
htmx:beforeSwap    → htmx:before:swap
htmx:configRequest → htmx:config:request

Two things vanished entirely. The htmx:xhr:* events make no sense when there is no XHR, and the validation events are replaced by plain native browser form validation. The checker catches old names inside hx-on attributes too, which is where mine were hiding.

History stopped snapshotting the DOM

This one changes behavior rather than syntax. htmx 2 cached page snapshots in localStorage for back-button navigation, and if any third-party script had mutated the DOM, you'd restore a zombie page: the mutated markup with none of the JS that created it. I hit this years ago with a charting library and "solved" it by disabling history caching entirely. Turns out that was the eventual official answer.

htmx 4 just re-fetches the page on back navigation. With decent HTTP caching headers it's fast, and third-party scripts behave. If you really need local history, there's an opt-in hx-history-cache extension that stores snapshots in sessionStorage.

What you get in return

The cleanup pays for itself. Morph swaps, previously an add-on, are built in now: hx-swap="innerMorph" or hx-swap="outerMorph" uses the idiomorph algorithm to edit the existing DOM in place instead of nuking it. Focus, scroll position, video playback, Alpine state, all of it survives a swap. That alone is worth the upgrade for any list-heavy admin UI.

There's also a new hx-partial tag for updating multiple targets from one response, each with its own target and swap declared right in the server HTML:

<hx-partial hx-target="#messages" hx-swap="beforeend">
  <div class="message">Ticket moved to Done</div>
</hx-partial>
<hx-partial>
  <span class="badge">3 open</span>
</hx-partial>

Cleaner than the old hx-swap-oob ordering dance.

One last practical note

npm still has 2.x tagged as latest until early 2027, so a naive npm update won't drag you to 4.0 by accident. When you're ready, install it explicitly: npm install [email protected].

Total migration time for my little app: about forty minutes, mostly the inheritance fixes. If your htmx usage is bigger than mine, the checker output will be longer, but the shape of the work is the same. I've started keeping my hx-swap notes and the event name table in Snippet Ark, because I know I'll be cross-referencing them for another few weeks at least.