Reactivity (Dependency-Aware OOB Swaps)¶
Reactivity is opt-in. You can use PyJinHx with BaseComponent only — see Usage tiers. This guide covers Tier 3+: dependency-aware out-of-band HTMX swaps.
Prerequisites
- HTMX for transport and swap
Registry.request_scope()on every HTTP request- ClientBackend in middleware (recommended) so mutation routes need no framework kwargs on
render()
pyjinhx owns composition; HTMX owns transport and swap. Between them sits the state→view dependency graph — which regions must change when a piece of state changes. pyjinhx lets you declare that graph once, on the components, so a mutation route re-emits exactly the mounted regions that depend on what changed.
A region that depends on a dirtied key is reloaded and re-emitted only when its
value actually changed — its freshly computed state_hash() is compared against
the hash the client reported, and a matching hash is skipped.
Runnable example: a full FastAPI + htmx todo app lives in
examples/reactive_todo/— runuv run uvicorn examples.reactive_todo.app:app --reloadand watch the counter, total, and clear button update out-of-band (and get skipped by hash-gating when their value doesn't change).
See the Public API Index for every exported reactive symbol.
Make a component reactive¶
Subclass ReactiveComponent and declare both the react class keyword and a
load() classmethod — ReactiveComponent enforces both (a missing load() can't be
instantiated; a missing react is a definition-time error):
from pyjinhx import ReactiveComponent, MutationKey
class Keys(MutationKey):
TODOS = "todos"
class Counter(ReactiveComponent, react={Keys.TODOS}):
remaining: int
@classmethod
def load(cls) -> "Counter":
return cls(remaining=db.remaining()) # id defaults to "counter"
react— the state keys this component derives from, as a set ofMutationKeymembers. These are the keys you choose to name pieces of state (Keys.TODOS,Keys.USER) — not component ids or types, and not client-side watchers. The server simply intersects a component's declared keys with the route'sdirtiedkeys (and uses them to evict theload()cache): it's cache invalidation, not signals.load()— rebuilds the component from the current world, independent of any route.id— defaults to the kebab-cased class name (Counter→"counter",TodoCounter→"todo-counter"), since a type-singleton's identity is its type, soload()need not set one. Pass an explicitidonly for instance-keyed regions — multiple mounted instances of one type, e.g.cls(id=f"todo-row-{user_id}", ...).state_hash()— canonical SHA-256 of sorted JSON frommodel_dump(mode="json")withstate_hash_excludeapplied (idis excluded by default). Override for custom hashing or add fields tostate_hash_excludefor ephemeral UI-only state.depends_on()— optional runtime narrowing for load-cache indexing; the staticreactset must remain a superset. See Runtime dependencies.
Reactive components are stamped with data-pjx-id, data-pjx-type (the class
name), and data-pjx-hash on their root element automatically.
Making builtins reactive¶
Builtins can be subclassed straight into reactive components — a subclass inherits its ancestor's template and assets through the MRO:
from pyjinhx import MutationKey, ReactiveComponent
from pyjinhx.builtins import PJXBadge
class Keys(MutationKey):
TASKS = "tasks"
class LiveBadge(ReactiveComponent, PJXBadge, react={Keys.TASKS}):
@classmethod
def load(cls) -> "LiveBadge":
return cls(label=f"{db.open_tasks()} open", color="brand")
No template or CSS needed: LiveBadge renders PJXBadge's pjx_badge.html and ships
pjx-badge.css. Resolution is first found per kind — ship your own
live-badge.css next to the subclass and it replaces pjx-badge.css (the
template and JS still come from PJXBadge); ship live_badge.html and the
template is yours too. Additions go through the js=/css= fields.
One rule: subclass one component at a time. class X(PJXBadge, PJXCard) raises
at class definition — two templates is no template.
Fit: display builtins (PJXBadge, PJXProgress, PJXAvatarStack, PJXEmptyState, PJXCard). Stateful overlays (PJXModal, PJXDrawer, PJXPopover, PJXDropdown) are a poor fit — an OOB swap replaces the region's DOM, so an open dialog snaps shut mid-interaction.
Ship the client runtime¶
On root full-page renders, pjx.js is injected automatically unless the request
already carries a valid X-PJX-Mounted header (meaning the runtime is active in
the browser). First visits and requests without the header get the runtime; HTMX
requests from a page that already loaded it do not.
from pyjinhx import BaseComponent
class AppShell(BaseComponent):
... # app_shell.html is your full page template
For a raw Jinja layout (outside the component render path), drop in client_script():
from pyjinhx.client import client_script
# in your template context
{"pjx_runtime": client_script()}
The runtime attaches these headers to htmx requests:
| Header | Purpose |
|---|---|
X-PJX-Mounted |
Reactive regions currently in the DOM (id, type, hash, optional load) |
X-PJX-Assets |
URLs of <script src> and <link rel="stylesheet"> already loaded |
X-PJX-Trigger |
data-pjx-id of the element that started the request — sent only when a reactive root triggered it |
Wire FastAPIClientBackend via setup(app, ...) — see the
canonical snippet and
Client Backend. Mutation routes then call
Cls.render(key) with no extra kwargs — headers are read from the backend after
@mutates. Full-page routes call .render() plainly; boosted navigations skip
re-injecting pjx.js when X-PJX-Mounted is present.
Emit OOB swaps from your route¶
A mutation route does exactly one thing: return <component>.render(...). You
never call load() and never assemble swaps yourself. For a reactive primary,
call render() on the class — it auto-load()s the component for you. The
dependent regions ride along as out-of-band swaps:
With @mutates on the store method, pending dirtied keys drive OOB swaps automatically.
Cls.render(*args) loads the primary (load(*args) for keyed types, load() for
singletons), renders it as the main-target response, then appends OOB swaps for every
other mounted reactive region whose react keys intersect pending mutations from
@mutates. Only the primary id is excluded (htmx swaps it as the main-target response);
the region that initiated the request still updates out-of-band if it depends on the
dirtied keys — e.g. a "Clear completed (N)" button updates its own count.
X-PJX-Trigger is client-only: pjx.js reads it to drive loading indicators (which
region the user clicked). The server reactive walk (render / oob_swaps) never reads it —
it excludes only the primary id and gates everything else on react keys and hashes.
A plain, non-reactive primary has no load() to call, so you build it and render
the instance: MyFragment(id=..., ...).render().
OOB swaps ride along any render¶
The fan-out is not exclusive to ReactiveComponent.render(). Any component's
.render() appends OOB swaps for dirtied mounted reactive regions when a client
backend is active and mutations occurred in the request — including a non-reactive
command-result view. Fan-out happens once per request scope and never double-swaps a
region already present in the response body.
@app.post("/generate")
def generate():
report = controller.generate() # @mutates dirties "reports", "quota"
return ReportSummary(report=report).render() # non-reactive; counters fan out OOB
For a response that renders no component at all (a raw string, a 204), use
from pyjinhx.reactive import reactive_response to attach the same fan-out:
from pyjinhx.reactive import reactive_response
@app.post("/dismiss")
def dismiss():
controller.dismiss() # @mutates dirties mounted regions
return reactive_response("") # no primary; dependents still fan out OOB
Without ClientBackend
Wire ClientBackend in middleware (via setup()) so render() reads manifest and asset headers automatically. Without a backend, reactive OOB is skipped when mutations are pending.
Under the hood: oob_swaps()¶
render() delegates its dependency walk to oob_swaps(dirtied, mounted) — hash-gate, nesting-dedup, and delete-on-LookupError in one call. It's exported for tests and advanced composition, but routes return render(), not bare swaps. Full walk mechanics are in How it works (under the hood) below.
The dependency graph lives in exactly one place — the react class keyword
declarations — not smeared across endpoints. Adding a progress bar that declares
react={Keys.TODOS} makes it participate automatically; no endpoint changes.
Instance-keyed regions (rows)¶
A reactive type can have many mounted instances — table rows, cards, list items.
A component is instance-keyed iff its load() takes one resource parameter after
cls; declare exactly one PjxKey field on the model:
from typing import Annotated
from pyjinhx import MutationKey, PjxKey, ReactiveComponent
class Keys(MutationKey):
TODOS = "todos"
class TodoItemRow(ReactiveComponent, react={Keys.TODOS}):
todo_id: Annotated[int, PjxKey()]
title: str = ""
done: bool = False
@classmethod
def load(cls, todo_id: int | str) -> "TodoItemRow":
resolved_id = int(todo_id) # cache wrapper passes the key as a string
t = store.get(resolved_id)
return cls(
id=f"row-{resolved_id}",
todo_id=resolved_id,
title=t.text,
done=t.done,
)
The load() key arrives as a string from the cache wrapper (the manifest serialises to JSON), so annotate int | str and convert inside load().
data-pjx-loadis stamped from thePjxKeyfield and returned in the manifest so OOB reloads callload(manifest.load).- Templates use the field directly:
hx-post="/rows/{{ todo_id }}/toggle". reactlists state keys only (e.g.{Keys.TODOS}). Pub-sub OOB reloads every mounted row whosereactkeys intersect pending mutations; hash-gating skips unchanged rows.
@mutates(Keys.TODOS)
def toggle(todo_id: int) -> Todo:
...
@app.post("/rows/{todo_id}/toggle")
def toggle_row(todo_id: int):
store.toggle(todo_id)
return TodoItemRow.render(todo_id)
When a keyed entity is removed but still listed in the client's mounted manifest (e.g.
after clear completed), oob_swaps catches LookupError from load(manifest.load)
and emits a delete OOB swap (delete:[data-pjx-id='…']) so stale row regions are removed
from the DOM without a server error.
State keys¶
Centralize reactive key strings in a MutationKey enum so react=, dirtied, and
@mutates share one vocabulary:
from pyjinhx import MutationKey, ReactiveComponent
class Keys(MutationKey):
TODOS = "todos"
class TodoCounter(ReactiveComponent, react={Keys.TODOS}):
...
Both react= and @mutates only accept MutationKey members — passing a bare
string raises TypeError at class-definition time (for react=) or at decoration time
(for @mutates).
Runtime dependencies (depends_on)¶
When a component's dependencies depend on loaded state, declare a static superset
via react= and override depends_on() to narrow at runtime:
class Keys(MutationKey):
USER = "user"
SETTINGS = "settings"
class AdminPanel(ReactiveComponent, react={Keys.USER, Keys.SETTINGS}):
is_admin: bool = False
@classmethod
def load(cls) -> "AdminPanel":
user = get_current_user()
return cls(is_admin=user.is_admin)
def depends_on(self) -> set[str]:
if self.is_admin:
return {Keys.USER, Keys.SETTINGS}
return {Keys.SETTINGS}
depends_on() narrows load-cache reverse indexing; oob_swaps and dependency_graph() always use the static react superset, and dev mode warns (or raises) when depends_on() returns keys outside it.
Mutation tracking (@mutates)¶
Decorate store mutation methods to invalidate the load() cache and accumulate
dirtied keys for the current request. The next reactive render() uses pending keys
from @mutates for OOB pub-sub:
from pyjinhx import mutates
@mutates(Keys.TODOS)
def toggle(todo_id: int) -> Todo:
...
@app.post("/rows/{todo_id}/toggle")
def toggle_row(todo_id):
store.toggle(todo_id)
return TodoItemRow.render(todo_id)
Use Registry.request_scope() on every request when relying on @mutates — it
resets mutation tracking per request.
Load context¶
Pass request-scoped dependencies into load() without global imports:
from dataclasses import dataclass
from pyjinhx import MutationKey, PjxContext, ReactiveComponent
class Keys(MutationKey):
TODOS = "todos"
@dataclass(frozen=True)
class AppContext(PjxContext):
db: Database
class Counter(ReactiveComponent, react={Keys.TODOS}):
@classmethod
def load(cls, *, ctx: AppContext | None = None) -> "Counter":
ctx = ctx or PjxContext.current()
return cls(remaining=ctx.db.remaining())
Set context per request via setup(app, context_factory=...) or
Registry.request_scope(load_context=AppContext(db=...)). Cache keys remain
(class, load_arg) — context is not part of the cache identity.
Development mode¶
Enable guardrails during local development:
from pyjinhx.dev import enable_reactive_dev
enable_reactive_dev() # warnings
enable_reactive_dev(strict=True) # raise instead
Checks include:
- mutations recorded via
@mutatesbut no reactiverender()in the same request scope - mutations pending but no
ClientBackendactive (OOB swaps skipped) depends_on()keys outside the staticreactsuperset
Inspect the dependency graph at startup:
from pyjinhx.dev import dependency_graph, format_dependency_graph
print(format_dependency_graph())
# or format_dependency_graph(as_mermaid=True) for a flowchart
load() results are cached¶
Every reactive component's load() is wrapped in a dependency-keyed cache.
Repeated reads within the same request return the cached result and skip the database
until the relevant keys are dirtied:
Cache scope¶
You don't choose a scope — it follows the backend. By default (no invalidation_backend),
caching is per request; configuring a cross-worker backend extends it to cross-request per
worker process.
| Backend | Storage | Cross-request | Multi-worker safe |
|---|---|---|---|
| none (default) | ContextVar inside Registry.request_scope() |
no | yes |
| configured | module-level dict per worker, fanned out on mutation | yes | yes (backend keeps workers consistent) |
from pyjinhx import setup
setup(app) # per-request caching (default, multi-worker safe)
setup(app, invalidation_backend=...) # cross-request per worker
Use Registry.request_scope() on every HTTP request (middleware) for instance registry
isolation and the request-tier cache (which dedups the OOB walk regardless of backend).
Cache identity: entries are keyed by (component class, load key) only. For per-user
isolation use a PjxKey-keyed instance (one entry per user id) or ensure PjxContext
data is stable for all requests sharing a cache entry.
Reactive render() (and oob_swaps) evicts pending dirtied keys before reloading
dependents. For mutations outside a render — a background job, a webhook — call
LoadCache.invalidate yourself:
from pyjinhx.cache import LoadCache
def nightly_recalc():
db.rebuild_todos()
LoadCache.invalidate({Keys.TODOS})
The cache holds one result per (type, key) and returns a fresh copy on every call, so
callers can mutate what they get back without affecting the cache.
Multi-worker invalidation¶
For multi-worker production, configure an InvalidationBackend so invalidate() fans out
to every process. This is also what enables cross-request caching per worker:
from pyjinhx import PjxSettings, setup
from pyjinhx.integrations.redis import RedisInvalidationBackend
setup(
app,
settings=PjxSettings(
invalidation_backend=RedisInvalidationBackend("redis://localhost:6379/0"),
),
)
Requires pip install pyjinhx[redis]. See Redis integration.
Loading indicators (in-flight)¶
A reactive region can show a loading indicator while an update is in flight, then swap in the
fresh HTML when the response arrives. You opt in in the template by adding a
data-pjx-loading attribute to whichever element(s) should show the effect — the component
root, or any element inside it:
<!-- item_row.html: shimmer the whole row while it reloads -->
<li class="todo" data-pjx-loading="skeleton">…</li>
<!-- clear_button.html: spin just this button -->
<button class="clear" data-pjx-loading="spinner">Clear completed ({{ completed }})</button>
Two built-in styles:
"skeleton"— a silhouette shimmer in place of the element's content (the box keeps its shape; the content is hidden while it shimmers)."spinner"— a dim, blurred overlay with a centered circular progress indicator; the content stays underneath and the element is non-interactive while loading.
Auto-triggered — no per-route wiring. Every reactive root is stamped with data-pjx-reacts
(its react keys). When an htmx request starts, pjx.js reads the triggering region's
data-pjx-reacts as the predicted dirtied set, then lights the data-pjx-loading elements of
every mounted region whose keys intersect it — the swap target and its out-of-band
dependents. Declaring the react class keyword is all it takes for a component's loading
elements to fire on the right mutations; routes don't change.
- A loading element is matched through its enclosing reactive root, so it can sit on the root or any inner element; the root supplies the reactivity and the instance key.
- Instance-keyed rows stay scoped: the template renders per instance, so each instance
carries the attribute, but only the instance whose
data-pjx-loadmatches the trigger (plus singleton dependents) lights up — clicking one row doesn't shimmer its siblings. - Indicators are ref-counted across overlapping requests and re-applied across swaps, so a shared dependent stays lit until the last in-flight request finishes.
- The class clears once the response settles (swapped regions replace themselves; hash-gated, aborted, and errored requests are released too). Purely a client affordance — no server reactive semantics change, and it is off unless an element opts in.
A trigger can also carry data-pjx-loading-extra="<css-selector>" to light regions the
dependency walk can't predict — e.g. the specific rows a bulk action like "clear completed" is
about to remove. Matched regions use their own data-pjx-loading style.
Styling and overrides¶
Both styles read overridable CSS custom properties (with sensible defaults), so you can restyle
them from your own CSS without touching the runtime — set the tokens on :root, a theme
wrapper, or a specific element:
:root {
/* spinner */
--pjx-spinner-color: #b8ff4d; /* the moving arc */
--pjx-spinner-track: rgba(255, 255, 255, 0.4);
--pjx-spinner-overlay: rgba(0, 0, 0, 0.45); /* dim/scrim behind it */
--pjx-spinner-blur: 2px;
--pjx-spinner-size: 1.1em;
--pjx-spinner-thickness: 2px;
--pjx-spinner-speed: 0.6s;
/* skeleton */
--pjx-skeleton-color: rgba(127, 127, 127, 0.12); /* base */
--pjx-skeleton-highlight: rgba(127, 127, 127, 0.30); /* shimmer sweep */
--pjx-skeleton-radius: 6px;
--pjx-skeleton-speed: 1.2s;
}
Want a different effect entirely? Use your own value (e.g. data-pjx-loading="pulse") and
style .pjx-loading--pulse yourself — pjx.js applies .pjx-loading--<value> regardless of
the name.
How it works (under the hood)¶
The ownership split. Neither pyjinhx nor htmx owned the state→view dependency
graph before; now it is explicit. The server owns the graph and the data and
decides what changed; the client owns what is currently mounted and rides that up on
every request as X-PJX-Mounted. There is no per-session server state — reactive roots
are stamped with data-pjx-* at render time, and pjx.js reads the already-stamped DOM
on htmx:configRequest (it never watches for changes; a DOM mutation is the effect of
a swap, not its cause).
A mutation route returns Cls.render(). That invalidates the load() cache for the
dirtied keys, renders the primary as the main response, then runs the oob_swaps walk
(with exclude_ids = {primary.id}) over the mounted manifest. Every region runs this
gauntlet — and ordering matters: hash-gate before nesting-dedup, so an unchanged
parent never suppresses a changed child.
flowchart TD
M["manifest entry"] --> F{"reactive type AND<br/>react keys intersect dirtied?"}
F -->|no| X1["ignore"]
F -->|yes| EX{"id in exclude_ids?<br/>(it is the primary)"}
EX -->|yes| X2["ignore"]
EX -->|no| LOAD["cls.load() cached →<br/>render → fresh hash"]
LOAD --> GATE{"fresh hash ==<br/>reported hash?"}
GATE -->|yes| X3["SKIP — value unchanged"]
GATE -->|no| DED{"nested inside another<br/>surviving region?"}
DED -->|yes| X4["DROP — parent already contains it"]
DED -->|no| EMIT["emit hx-swap-oob fragment"]
The four parent/child cases (regions nested in the rendered HTML):
| Parent | Child | Result |
|---|---|---|
| changed | changed | swap parent only (its fresh HTML already holds the child) |
| changed | unchanged | swap parent only |
| unchanged | changed | swap child alone — only correct because gating removes the parent before dedup |
| unchanged | unchanged | swap nothing |
Governing invariant throughout: when in doubt, swap — missing, unknown, or
mismatched hashes always send. Hash gating is a skip-hint, not correctness authority:
it saves bandwidth and DOM churn, while database work is saved separately by the
load() cache (each cached load() returns a model_copy(), so the DB is hit only on
a miss; writes evict by dependency through a reverse index, guarded by a lock around the
consult-then-mutate while the real load() runs outside it).