Skip to content

Loading states

Six loading niches, one decision rule: what is missing?

What's missing Builtin Activation
Content not loaded yet PJXSkeleton None — it IS the placeholder; the swap replaces it (PJXLazyPanel(content=PJXSkeleton()))
Reactive region refreshing runtime state (data-pjx-loading) Automatic — add data-pjx-loading="skeleton" or data-pjx-loading="spinner" directly in the ReactiveComponent template (on the root element or an inner element); the pjx.js runtime shimmer-skeletonizes or overlays the EXISTING content while predicted mutations are in flight
Request in flight, inline PJXSpinner PJXSpinner(class_name="htmx-indicator") + hx-indicator="#its-id" — htmx toggles it, zero JS
Request in flight, region-blocking PJXRegionLoader hx-indicator pointing at it, or pjx.loader.region.show/hide/reset(id) for non-htmx work (pick one mechanism per element), or pjx.loader.region.wrap(id, promise)
Request in flight, page navigation PJXPageLoader Automatic: htmx lifecycle events for GETs targeting nav_targets, plus any request from inside [data-pjx-loader]; pjx.loader.page.wrap(promise) for manual async
Determinate progress PJXProgress value/max props

PJXSkeleton component vs skeleton state. The PJXSkeleton component is placeholder DOM for content that doesn't exist yet (first paint, PJXLazyPanel placeholders). The runtime's pjx-loading--skeleton state shimmer-masks content that already exists while it refreshes. Same look, different jobs — and different theming tokens (the component's --pjx-skeleton-bg/--pjx-skeleton-shine vs the runtime's --pjx-skeleton-color/--pjx-skeleton-highlight).

<!-- inline: search box with a spinner -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-indicator="#search-spin">
<PJXSpinner id="search-spin" class_name="htmx-indicator"/>

<!-- region: a card that blocks while refreshing -->
<div hx-get="/stats" hx-trigger="every 60s" hx-indicator="#stats-overlay">
  <PJXRegionLoader id="stats-overlay"/>
  ...
</div>

htmx-request is htmx's own class; without htmx these indicators simply stay hidden.

Concurrency

Overlapping or back-to-back triggers from independent sources are safe across the family — but the state lives in different places by design:

Component Who tracks overlapping triggers
PJXRegionLoader its own per-id ref-count: visible from the first show(id) to the last hide(id); a show during an in-flight hide cancels it silently; wrap(id, promise) pairs the calls for you; nodes replaced mid-flight are re-lit after htmx settles
PJXPageLoader its own request ref-count across all tracked htmx requests (plus pjx.loader.page.wrap)
PJXSpinner (as hx-indicator) htmx itself — htmx keeps a per-indicator request count and only removes htmx-request when the last overlapping request settles
PJXSkeleton nobody — it's inert placeholder markup (the runtime skeleton state is a different thing: the pjx.js runtime ref-counts it per region and re-lights replaced nodes after swaps)
runtime loading states (data-pjx-loading) the pjx.js runtime — per-region ref-count keyed to each request's loadend (fires on load/error/abort, even for superseded responses); regions replaced mid-flight are re-lit after settle
PJXProgress the server — concurrent updates are last-write-wins value swaps

The atoms deliberately hold no client state: adding ref-counts to them would double-manage what htmx or the server already owns.

Singletons

PJXToastHost and PJXPageLoader are mounted once in your layout. PJXPageLoader's active_on_load state clears on full page loads — don't deliver one via a fragment swap.