DEV Community

Cover image for How to do partial page updates without shipping a framework
Alan West
Alan West

Posted on

How to do partial page updates without shipping a framework

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 <div>. Simple, right?

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.

Why the web makes this awkward

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 (<a href>, <form action>), or you can call fetch() and write the result into innerHTML yourself. There's no middle ground built into the platform.

That gap is why entire categories of tools exist:

  • HTMX turns attributes into AJAX swaps
  • Turbo (Hotwire) wraps regions in <turbo-frame> and intercepts navigation
  • React/Vue/Svelte rebuild the page from a virtual representation
  • Unpoly and friends do progressive enhancement around forms

Each of them is solving a problem the browser itself never tried to solve.

A quick tour of the pain

Here's the typical "just fetch and replace" approach:

// 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;
}
Enter fullscreen mode Exit fullscreen mode

Looks fine. But:

  • Inline <script> tags in html won't run (a long-standing quirk of innerHTML).
  • Any event listeners attached to the old #content children are gone.
  • If the user had focus inside the replaced region, it's lost.
  • Custom elements may re-run constructors in surprising ways.

After getting bitten by all of these in production, most teams reach for a library.

How HTMX solves it today

HTMX handles a lot of this for you with declarative attributes. The swap target and strategy live in the markup:

<button
  hx-get="/api/comments?page=2"
  hx-target="#comments"
  hx-swap="beforeend"
>
  Load more
</button>

<ul id="comments">
  <!-- existing items -->
</ul>
Enter fullscreen mode Exit fullscreen mode

It handles script execution, optional out-of-band swaps, focus preservation via hx-preserve, and history integration. Worth reading the official docs if you haven't.

Turbo Frames, briefly

If you're in the Rails/Hotwire world, <turbo-frame> does similar work by wrapping regions and intercepting links inside them:

<turbo-frame id="comments" src="/posts/42/comments">
  Loading…
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

Both approaches work. Both require shipping a library. Both reinvent things the browser arguably should handle itself.

What Chrome is reportedly exploring

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.

What to do in the meantime

If you're feeling the pain today, here's the order I'd try things in:

  1. Use the simplest tool that fits. If you only need partial updates in two places, hand-rolled fetch plus replaceChildren is fine.
  2. Reach for HTMX or Turbo if you're doing this everywhere. Don't build your own framework. I've watched two teams try; both ended up with a worse HTMX.
  3. Keep server-rendered HTML as the source of truth. Returning JSON and reconstructing markup on the client is what got us into the SPA mess in the first place.

Here's a safer vanilla pattern I lean on when a library would be overkill:

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);
}
Enter fullscreen mode Exit fullscreen mode

A few notes on why this is less awful than the naive version:

  • <template> 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.
  • We still need to manually re-run any inline scripts the server sends. Honestly, I just don't.

Prevention tips so this doesn't haunt you again

A handful of habits that have saved me grief:

  • Pick one swap strategy per project. Mixing HTMX, Turbo, and hand-rolled fetches makes debugging miserable.
  • Test focus, scroll, and selection as part of normal QA — not just "did the right content appear."
  • Treat the response shape as a contract. If one endpoint returns JSON for some callers and HTML for others, you've doubled your test surface.
  • Watch the standards work. If a declarative API does land in browsers, your migration story will be much smoother if your current solution is markup-first rather than imperative JS scattered across the codebase.

Wrapping up

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.

Top comments (0)