The problem: swapping part of a page is harder than it should be
You click a "Load more" button. Behind the scenes, your app fetches some HTML and shoves it into a Except it never stays simple. You hit edge cases with orphaned event listeners, focus jumping around, scripts that mysteriously don't execute, and weird flashes when you replace a chunk of DOM. I ran into this again last month while refactoring a dashboard. The team had jQuery doing partial swaps for the better part of a decade and finally wanted out. The "modern" alternatives all looked great in demos. Each one came with its own quirks. HTML, at its core, doesn't have a primitive for "replace this region with the contents of that response." You can navigate the whole page ( That gap is why entire categories of tools exist: Each of them is solving a problem the browser itself never tried to solve. Here's the typical "just fetch and replace" approach: Looks fine. But: After getting bitten by all of these in production, most teams reach for a library. HTMX handles a lot of this for you with declarative attributes. The swap target and strategy live in the markup: It handles script execution, optional out-of-band swaps, focus preservation via If you're in the Rails/Hotwire world, Both approaches work. Both require shipping a library. Both reinvent things the browser arguably should handle itself. According to the Chrome developers blog post on declarative partial updates, there's an early-stage proposal to give the platform a native way to express "replace this region with what comes back from the server." I haven't shipped anything against the proposal yet — at the time of writing it reads as an explainer, not a stable API — but the direction is interesting. The general idea, as I read it, is that you describe the swap declaratively in markup and the browser does the fetch and DOM update for you. Think of it as the platform absorbing patterns that libraries like HTMX have demonstrated work well. If you want to follow along officially, the right places to watch are: I would resist building anything load-bearing on a proposal-stage API. The shape will almost certainly shift. If you're feeling the pain today, here's the order I'd try things in: Here's a safer vanilla pattern I lean on when a library would be overkill: A few notes on why this is less awful than the naive version: A handful of habits that have saved me grief: Partial page updates are one of those problems that look tiny from the outside and turn into a tar pit on the inside. Libraries have papered over the gap for years and done a respectable job. If browsers eventually expose a native primitive for this, a lot of glue code will go away. Until then: pick a sane tool, keep markup as the source of truth, and don't roll your own. I've tried. It's not worth it.Why the web makes this awkward
, ), or you can call fetch() and write the result into innerHTML yourself. There's no middle ground built into the platform. and intercepts navigationA quick tour of the pain
// Replace #content with HTML from the server
async function loadFragment(url) {
const res = await fetch(url);
const html = await res.text();
document.querySelector('#content').innerHTML = html;
} tags in html won't run (a long-standing quirk of innerHTML).#content children are gone.How HTMX solves it today
<button
hx-get="/api/comments?page=2"
hx-target="#comments"
hx-swap="beforeend"
>
Load more
</button>
<ul id="comments">
<!-- existing items -->
</ul>hx-preserve, and history integration. Worth reading the official docs if you haven't.Turbo Frames, briefly
does similar work by wrapping regions and intercepting links inside them:<turbo-frame id="comments" src="/posts/42/comments">
Loading…
</turbo-frame>What Chrome is reportedly exploring
What to do in the meantime
fetch plus replaceChildren is fine.async function swapFragment(targetSelector, url) {
const res = await fetch(url, { headers: { Accept: 'text/html' } });
if (!res.ok) throw new Error(`Fragment fetch failed: ${res.status}`);
const html = await res.text();
const template = document.createElement('template');
template.innerHTML = html; // parsed in an inert context, not executed
const target = document.querySelector(targetSelector);
// replaceChildren preserves more state than reassigning innerHTML
target.replaceChildren(...template.content.childNodes);
} parses HTML in an inert context, so custom elements don't upgrade twice.replaceChildren is generally kinder to scroll position and selection than overwriting innerHTML.Prevention tips so this doesn't haunt you again
Wrapping up
