Build an App (step by step)¶
This guide walks a complete path from zero to a reactive FastAPI + HTMX app with PyJinHx. Each step shows what to do and a Why? panel explaining why it exists.
When you're done you will have used:
BaseComponentandReactiveComponent- Template discovery and nesting via typed child fields
- Co-located JS/CSS and asset delivery modes
Registry.request_scope,@mutates, andPjxContext- Reactive
render()withClientBackendwired in middleware - Load-cache scopes and optional invalidation fan-out
Runnable reference: examples/reactive_todo/.
What you are building¶
A small todo app:
- Full page on
GET /— layout, list, counter. - Partial updates on
POST— toggle a row; counter updates out-of-band. - No manual swap wiring — components declare dependencies; routes call
render().
flowchart LR
Browser -->|HTMX POST| Route
Route -->|mutate store| Store
Route -->|Cls.render| PyJinHx
PyJinHx -->|primary HTML| Browser
PyJinHx -->|OOB fragments| Browser
Step 0 — Install and project layout¶
my_app/
├── app.py # FastAPI routes
├── store.py # mutations + @mutates
├── keys.py # reactive key enums
├── components/
│ ├── todo_counter.py
│ ├── todo_counter.html
│ ├── todo-counter.js
│ ├── todo_list.py
│ ├── todo_list.html
│ ├── todo_panel.py
│ ├── todo_panel.html
│ ├── todo_item_row.py
│ ├── todo_item_row.html
│ ├── todo_app.py
│ └── todo_app.html
└── pyproject.toml
Why this layout?
PyJinHx discovers templates next to component classes. Keeping components/ as the template root lets Renderer.set_default_environment("./components") resolve every .html file without manual paths. Separating store.py from components mirrors how a real app keeps domain logic out of UI classes.
Step 1 — Your first component¶
components/todo_counter.py:
components/todo_counter.html:
Smoke test in a shell:
from pyjinhx import Renderer
from components.todo_counter import TodoCounter
Renderer.set_default_environment("./components")
print(TodoCounter(id="counter", remaining=3).render())
Why BaseComponent and a stable id?
BaseComponent is a Pydantic model — fields are validated at construction time. The id is the stable DOM identity: HTMX targets, registry lookups, and reactive data-pjx-id stamping all depend on it. Omitted ids auto-generate a pjx-<n> value; reactive components additionally default to the kebab-cased class name. Explicit ids matter when you need a stable swap target across requests.
Why set_default_environment?
The renderer needs one search root for templates (and co-located assets). You set it once at startup (module import or app factory), not per render.
Step 2 — Compose in Python¶
components/todo_list.py:
from pyjinhx import BaseComponent
class TodoList(BaseComponent):
id: str
items: list[BaseComponent] = []
components/todo_list.html:
Build the tree in Python:
from components.todo_counter import TodoCounter
from components.todo_list import TodoList
page = TodoList(
id="todo-list",
items=[TodoCounter(id="counter", remaining=3)],
)
print(page.render())
Why compose in Python?
Python composition gives you type checking and explicit structure — IDE autocomplete on fields, Pydantic validation on nested components. Use this when the page structure is decided server-side (typical for app shells and data-heavy views).
See also: Nesting.
Step 3 — A panel with typed child fields¶
components/todo_panel.py:
from pyjinhx import BaseComponent
from components.todo_counter import TodoCounter
class TodoPanel(BaseComponent):
id: str
counter: TodoCounter
components/todo_panel.html:
Build it in Python; the template decides where the child renders:
Why typed child fields?
The panel declares which child it holds as a typed Pydantic field; the template owns where it goes — {{ counter }} renders the nested component in place. This is the same style the runnable examples/reactive_todo app uses. PyJinHx also supports <PascalCase/> tags for template-driven composition — see PascalCase tags.
Step 4 — Co-located assets¶
Add components/todo-counter.js next to todo_counter.py (asset filenames are the kebab-cased class name):
On the root render, PyJinHx collects JS/CSS once and injects it (inline by default):
TodoPanel(id="panel", counter=TodoCounter(id="counter", remaining=2)).render()
# → <style>...</style> HTML <script>...</script>
Why co-located assets?
Components carry their own behavior and styling. Collecting at the root render avoids duplicate script tags when nested components share assets. Reactive partials and OOB swaps never emit assets — only full layout renders do.
Production: switch to AssetMode.REFERENCE and a URL resolver. See Asset collection.
Step 5 — FastAPI shell¶
app.py:
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from pyjinhx import Renderer
from components.todo_counter import TodoCounter
from components.todo_panel import TodoPanel
Renderer.set_default_environment("./components")
app = FastAPI()
@app.get("/", response_class=HTMLResponse)
def index():
return TodoPanel(id="panel", counter=TodoCounter(id="counter", remaining=3)).render()
Run: uvicorn app:app --reload
Why FastAPI + HTMLResponse?
PyJinHx renders Markup (safe HTML strings). FastAPI's HTMLResponse accepts that directly. Any WSGI/ASGI framework works — PyJinHx is not tied to FastAPI.
See: FastAPI integration.
Step 6 — Request scope (registry + cache hygiene)¶
Per-route wrapping works for demos:
from pyjinhx import Registry
@app.get("/", response_class=HTMLResponse)
def index():
with Registry.request_scope():
return TodoPanel(id="panel", counter=TodoCounter(id="counter", remaining=3)).render()
For a real app, use setup(app, ...) so lifespan and middleware are wired automatically:
from pyjinhx import setup
app = FastAPI()
setup(app, context_factory=lambda request: AppLoadContext(store=store)) # AppLoadContext defined in Step 12
See Configuration API and FastAPI integration.
Step 7 — HTMX partial responses¶
Load HTMX in your layout template:
Return a fragment from a mutation route:
@app.post("/counter/bump", response_class=HTMLResponse)
def bump():
return TodoCounter(id="counter", remaining=2).render()
Note
Middleware from Step 6 already wraps each request — no per-route request_scope() needed.
Template button:
Why HTMX?
PyJinHx owns HTML composition; HTMX owns transport and swap. You keep server-rendered components and avoid a client-side state tree. PyJinHx does not replace HTMX — they meet at the route return value.
See: HTMX integration.
Step 8 — Reactive components¶
Upgrade the counter. It names the state it derives from with a Keys enum and
reads from a store module — we define both keys.py and store.py in Step 9;
for now just note that Keys.TODOS and store are imported from there:
from pyjinhx import ReactiveComponent
from keys import Keys
import store
class TodoCounter(ReactiveComponent, react={Keys.TODOS}):
remaining: int = 0
@classmethod
def load(cls) -> "TodoCounter":
return cls(id="counter", remaining=store.remaining())
Define the page shell as a normal BaseComponent — no special marker required:
Why ReactiveComponent?
Reactive components declare what state they derive from (the react class keyword) and how to rebuild (load()). After a mutation, you return one primary fragment; PyJinHx appends OOB swaps for other mounted regions whose dependencies overlap — you don't list every widget in every route.
Root full-page renders inject pjx.js automatically unless the request already carries X-PJX-Mounted. That runtime sends the manifest on every HTMX request so the server knows what's on screen.
See: Reactivity.
Step 9 — Keys, mutations, and render()¶
Centralize reactive key strings in a MutationKey enum so react=, @mutates, and
dirtied all share one vocabulary (no stray raw strings to typo). keys.py:
store.py:
from pyjinhx import mutates
from keys import Keys
_todos: dict[int, dict] = {}
_next_id = 1
def remaining() -> int:
return sum(1 for t in _todos.values() if not t["done"])
def get(todo_id: int) -> object:
return type("Todo", (), _todos[todo_id])()
@mutates(Keys.TODOS)
def add(text: str) -> None:
global _next_id
_todos[_next_id] = {"text": text, "done": False}
_next_id += 1
@mutates(Keys.TODOS)
def toggle(todo_id: int) -> None:
_todos[todo_id]["done"] = not _todos[todo_id]["done"]
Route (the TodoItemRow it renders is the instance-keyed row we define in Step 10):
@app.post("/rows/{todo_id}/toggle", response_class=HTMLResponse)
def toggle_row(todo_id: int):
store.toggle(todo_id)
return TodoItemRow.render(todo_id)
Why @mutates and ClientBackend?
@mutates— after a store change, invalidate theload()cache and accumulate pending state keys for the next reactiverender().ClientBackend(wired viasetup()) — suppliesX-PJX-Mounted,X-PJX-Trigger, andX-PJX-Assetsso OOB swaps run without framework kwargs onrender().
render() on the class auto-calls load() — routes never call load() manually.
Step 10 — Instance-keyed rows¶
from typing import Annotated
from pyjinhx import PjxKey
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
todo = store.get(resolved_id)
return cls(
id=f"row-{resolved_id}",
todo_id=resolved_id,
title=todo.text,
done=todo.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().
Template (note data-pjx-loading — covered in Step 11):
<li data-pjx-loading="skeleton">
<button hx-post="/rows/{{ todo_id }}/toggle"
hx-target="closest [data-pjx-id]" hx-swap="outerHTML">toggle</button>
<span>{{ title }}</span>
</li>
Why PjxKey and load(cls, todo_id)?
A parameter after cls makes the type instance-keyed. PjxKey stamps data-pjx-load for OOB round-trip. Use the same field in templates ({{ todo_id }}). react={Keys.TODOS} is pub-sub — all mounted rows with matching state keys may OOB-reload when todos change.
Step 11 — Loading states (in-flight indicators)¶
While a reactive region's OOB update is in flight, it can show a built-in indicator.
You opt in in the template by adding data-pjx-loading to any element — the
component root or something inside it. No route or Python changes:
<!-- todo_item_row.html: shimmer the whole row while it reloads -->
<li data-pjx-loading="skeleton"> ... </li>
<!-- clear_button.html: spin just this button -->
<button data-pjx-loading="spinner">Clear completed ({{ completed }})</button>
Two built-in styles ship: "skeleton" (silhouette shimmer) and "spinner" (dimmed
overlay with a circular indicator). pjx.js reads each reactive root's react keys
and lights the matching data-pjx-loading elements on every mounted region a
mutation touches — the swap target and its OOB dependents.
Why template-driven, and how do I theme it?
Indicators are purely a client affordance — no server reactive semantics change, and
nothing fires unless an element opts in. Both styles read overridable --pjx-* CSS
variables (e.g. --pjx-skeleton-color, --pjx-spinner-color, --pjx-spinner-speed)
you can set on :root or any wrapper. Any other value (data-pjx-loading="pulse")
just applies .pjx-loading--pulse for you to style.
Step 12 — PjxContext (avoid globals in load())¶
The context is just a plain frozen dataclass — PjxContext.current() is duck-typed, so you don't need to subclass anything for this manual pattern (the annotation-injected ctx: parameter style does require a PjxContext subclass annotation):
from dataclasses import dataclass
from pyjinhx import PjxContext
@dataclass(frozen=True)
class AppLoadContext:
store: object
def _store():
ctx = PjxContext.current()
return ctx.store if isinstance(ctx, AppLoadContext) else store
Pass a factory to setup() (Step 6):
Why PjxContext?
load() must rebuild components from the current world. Passing a database handle or store through a request-scoped context avoids hidden globals and makes tests inject a fake store. Optional load(cls, *, ctx=...) is supported if you prefer explicit parameters.
Step 13 — Load cache scope and invalidation¶
You don't pick a cache scope — it follows the backend. By default (no invalidation_backend),
load() results cache within each HTTP request, which is multi-worker safe.
| Setup | Behavior |
|---|---|
| no backend (default) | Per-request caching — multi-worker safe without Redis |
invalidation_backend set |
Cross-request caching per worker, with evictions fanned out across workers |
Multi-worker fan-out (also enables cross-request caching):
import os
from pyjinhx import PjxSettings, setup
from pyjinhx.integrations.redis import RedisInvalidationBackend
setup(
app,
settings=PjxSettings(
invalidation_backend=RedisInvalidationBackend(os.environ["REDIS_URL"]),
),
)
Why cache at all?
A single page may call TodoCounter.load() many times during composition and OOB walks. Caching (class, load_arg) → component snapshot avoids repeated store/DB work. Invalidation (@mutates or LoadCache.invalidate) evicts entries when state changes — cache is a performance layer, not the source of truth.
If toggles feel stale, check that @mutates dirtied a key your rows actually
declare via react=. Rows here use pub-sub on {Keys.TODOS} — every mounted
row reloads when todos changes, and hash-gating skips the unchanged ones. (For
per-instance keys like "todo:42" instead of a shared stem, see
Reactivity → Instance-keyed regions.)
Step 14 — Production assets¶
from pyjinhx import AssetMode, Renderer
Renderer.set_default_js_mode(AssetMode.REFERENCE)
Renderer.set_default_css_mode(AssetMode.REFERENCE)
Renderer.set_asset_url_resolver(lambda path: f"/static/{path.split('/')[-1]}")
Renderer.set_default_asset_dedup(True) # hx-boost: skip already-loaded URLs
Full-page route with boosted navigation:
Why REFERENCE mode?
Inline <script> blocks are fine for demos but awkward in production (CSP, caching, size). REFERENCE emits <link> / <script src> URLs. Client dedup (X-PJX-Assets) avoids re-downloading assets on boosted full-page swaps.
Step 15 — Dev guardrails (optional)¶
from pyjinhx.dev import enable_reactive_dev, dependency_graph, format_dependency_graph
enable_reactive_dev() # warnings: missing mounted, unconsumed @mutates, etc.
print(format_dependency_graph())
Why enable_reactive_dev?
Reactivity bugs are often silent (missing ClientBackend, wrong react keys, depends_on() outside the react superset). Dev mode turns those into log warnings or strict exceptions during development.
Step 16 — Built-in UI kit (optional)¶
import pyjinhx.builtins # register templates
from pyjinhx.builtins import PJXAlert, PJXCard, PJXModal
Why builtins?
Optional ready-made components (PJXAlert, PJXCard, PJXModal, PJXPanel, …) with co-located CSS/JS. Use when you want a consistent kit without building every primitive. Your app components follow the same BaseComponent rules.
See: Built-in UI components.
Checklist — full app wiring¶
The per-step Why? panels above cover the why; this is the at-a-glance what.
| Tier | Pieces |
|---|---|
| Required | set_default_environment · Registry.request_scope() middleware · root full-page render · HTMX in layout · ReactiveComponent (react={...} + load()) · @mutates(Keys.…) on mutations · setup() (wires FastAPIClientBackend) · PjxKey on keyed rows |
| Recommended | PjxContext · data-pjx-loading indicators · enable_reactive_dev() in dev |
| Production | AssetMode.REFERENCE + URL resolver · InvalidationBackend for multi-worker PROCESS cache |
Where to go next¶
- Quick Start — minimal single component
- Reactivity — deep dive on OOB swaps and hash gating
- FastAPI · HTMX
- API: Renderer · Registry
- Live demo:
uv run uvicorn examples.reactive_todo.app:app --reload