Skip to content

Data region

A data region is a piece of UI that owns its own refresh lifecycle. It loads once on page render and then keeps itself up to date by polling, by listening for events, or by combining both. The component is just an htmx-driven container — no behavior helper needed.

Also known as: auto-refresh, polling region.

Press Simulate a change elsewhere — the endpoint answers 204 with HX-Trigger: {"items:changed":{}, "hc:toast":…}, and the region hears the event and refetches itself (watch the Rendered at time change). The endpoint is a namespaced demo implementation of the server response contract under api/recipes/data-region/.

The second region (Polling region) demos polling on a schedule: it refetches itself every 10 seconds — its re-rendered fragment carries every 10s only, and htmx re-arms the interval on each swap. Watch its Rendered at line tick over.

Items

Polling region

<section id="orders-summary"
class="hc-data-region"
data-hx-get="/orders/summary"
data-hx-trigger="load, every 10s"
data-hx-swap="outerHTML"
data-hx-indicator="closest .hc-data-region"
aria-busy="false">
<header class="hc-data-region__header">
<h2>Orders</h2>
<span class="hc-spinner htmx-indicator" aria-hidden="true"></span>
</header>
<p class="hc-field__message">Loading…</p>
</section>

Pieces:

  • data-hx-trigger="load, every 10s" — render once on page load, then again every 10 seconds. The server’s re-rendered fragment carries every 10s only: htmx re-arms the interval on each swap (that restart is the poll), and echoing load back would refetch forever.
  • data-hx-swap="outerHTML" — the region replaces itself with the complete <section> the server returns (same id, class, and attributes), so every swap is idempotent and the server stays in control of the region’s triggers.
  • data-hx-indicator="closest .hc-data-region" — a slow poll lights up the header’s .hc-spinner.htmx-indicator instead of flickering the content.

Always design the polled endpoint to be cheap — a fast COUNT query or a denormalized read model — and idempotent.

When you are retrofitting an existing fragment endpoint that returns only the inner content (no wrapper element), keep the triggers on a bare container and swap its contents instead:

<section id="orders-summary"
data-hx-get="/orders/summary"
data-hx-trigger="load, every 10s"
data-hx-swap="innerHTML">
<p class="hc-field__message">Loading…</p>
</section>
  • The container itself (with its triggers) stays put; only its contents are replaced, so there is nothing to re-arm and the trigger keeps ticking as written.
  • The trade-off: the server can no longer adjust the region — change the poll interval, stop polling, listen for a new event — because the fragment carries no attributes. Prefer the self-replacing hc-data-region form above when you control the server, and reserve this form for retrofits.

When the endpoint can only return the full page (a server-rendered view with no fragment route), let the region cut its replacement out of that page and swap itself:

<div id="page-content"
data-hx-get="/ops/console/outbox"
data-hx-trigger="every 15s"
data-hx-select="#page-content"
data-hx-target="this"
data-hx-swap="outerHTML">
</div>
  • data-hx-select="#page-content" extracts the matching element from the response; data-hx-swap="outerHTML" replaces the region with it, triggers and all.
  • This costs a full page render per tick — prefer a fragment endpoint (the self-replacing hc-data-region form, or the innerHTML form above) when you control the server, and reserve hx-select for retrofits.
  • With outerHTML the region’s every 15s timer restarts on each swap; with no load in the trigger the first paint is the server-rendered HTML, so there is no flash.

Pair polling with an htmx custom event so server actions can ask the region to refresh immediately:

<section id="orders-summary"
class="hc-data-region"
data-hx-get="/orders/summary"
data-hx-trigger="load, every 30s, orders:refresh from:body"
data-hx-swap="outerHTML"
data-hx-indicator="closest .hc-data-region"
aria-busy="false">
</section>

The server fires the event via HX-Trigger on any write endpoint:

HTTP/1.1 200 OK
HX-Trigger: {"hc:toast":{"message":"Created"},"orders:refresh":true}

from:body listens for the event on document.body, which is where htmx dispatches HX-Trigger events.

For data regions below the fold, defer the initial fetch until the region scrolls into view:

<section id="invoices-summary"
class="hc-data-region"
data-hx-get="/invoices/summary"
data-hx-trigger="intersect once, every 30s"
data-hx-swap="outerHTML"
data-hx-indicator="closest .hc-data-region"
aria-busy="false">
<p class="hc-field__message">Loading when visible…</p>
</section>
  • intersect once fires when the element enters the viewport, once.
  • The subsequent every 30s only starts ticking after the first fetch.

The server returns the complete <section> — same id, same class, same attributes — so the swap is idempotent, with one deliberate difference: the re-rendered fragment drops load from the trigger (htmx fires load on every freshly swapped element, so echoing it back would refetch forever):

<!-- GET /orders/summary -->
<section id="orders-summary"
class="hc-data-region"
data-hx-get="/orders/summary"
data-hx-trigger="every 10s"
data-hx-swap="outerHTML"
data-hx-indicator="closest .hc-data-region"
aria-busy="false">
<header class="hc-data-region__header">
<h2>Orders</h2>
<span class="hc-spinner htmx-indicator" aria-hidden="true"></span>
</header>
<dl class="hc-stack">
<div>
<dt>Open orders</dt>
<dd>14</dd>
</div>
<div>
<dt>Awaiting payment</dt>
<dd>3</dd>
</div>
</dl>
</section>

Because the fragment carries the region’s attributes, the server stays in control: it can change the poll interval, stop polling once the data is final, and render an explicit empty state when there are no rows. For the innerHTML retrofit form, return only the inner content (the <dl> above) instead.

Return the same content for both htmx (HX-Request: true) and full-page loads. Caching can be tuned with standard Cache-Control headers; htmx respects them.

RequestResponse
GET /orders/summary (poll, event, or reveal)200 + the complete <section> (innerHTML retrofit: the inner content only)
GET /orders/summary (no HX-Request — full page load)200 + the same content, wrapped in the full page
Any write endpoint that should refresh the region2xx + HX-Trigger: {"orders:refresh":true}
GET /orders/summary (failure)non-2xx — not swapped; the region keeps its previous rendering

htmx ≥ 2 does not swap non-2xx responses, so a failed poll leaves the previous rendering visible — the region degrades to slightly stale, never to broken. To tell the user, add an HX-Trigger: {"hc:toast":{…}} header to the error response (see the toast recipe).

Wrap a spinner in htmx-indicator if the polling endpoint is slow enough that flickering content matters. The hc-data-region form above already wires this up with data-hx-indicator="closest .hc-data-region". For the innerHTML form, put the request attributes on an element that contains the spinner, so the indicator selector can reach it (closest walks ancestors only; find searches descendants):

<section id="status"
data-hx-get="/status"
data-hx-trigger="load, every 5s"
data-hx-target="#status-body"
data-hx-swap="innerHTML"
data-hx-indicator="find header .hc-spinner">
<header>
<h2>Sync status</h2>
<span class="hc-spinner htmx-indicator" aria-hidden="true"></span>
</header>
<div id="status-body"></div>
</section>
  • Mark the region with aria-live="polite" when the user benefits from being told about changes (e.g. counters), or aria-live="off" if updates are noisy and decorative.
  • Do not move focus on each refresh. Polling that hijacks focus is hostile to keyboard users and screen readers alike.
  • Show an explicit empty state when the data set becomes empty so assistive tech does not encounter a silently empty section.
  • Without JavaScript, the region renders its initial server-side contents and never refreshes. Render reasonable defaults inside the section instead of leaving it blank.
  • Without htmx, the section behaves as plain HTML.
  • Background tabs throttle timers. Browsers slow down polling in inactive tabs; do not rely on every 5s for real-time accuracy.
  • Sync points. Pair polling with SSE or WebSockets when sub-second freshness matters. The data region recipe stays the same — only the trigger changes.
  • Cost. Per-second polling at scale is expensive. Prefer event-driven refresh (from:body) once you have a backplane.