Component Framework
Beta — the core lifecycle, permissions, composition, and testing utilities are stable, but the public API can still change before 1.0. See Installation.
Server-driven UI components for Python web frameworks, in the style of Phoenix LiveView and Laravel Livewire: state and event handling live on the server, and HTMX handles the client-side wiring instead of a JavaScript framework.
The problem this solves
Adding interactivity to a server-rendered Python app usually means one of two paths: hand-roll a pile of endpoint-specific JavaScript, or bring in a separate SPA framework (React, Vue) and split your app into a Python API plus a JS frontend. Both add real cost — a second toolchain and build pipeline for the SPA route, or a growing pile of bespoke fetch/DOM-patching code for the hand-rolled route.
Component Framework gives you a third option, closer to how Phoenix LiveView, Rails Hotwire, and Laravel Livewire work: your component's Python class owns state and event handlers, the server renders HTML (via Jinja2, JinjaX, or Django templates), and a single ~500-line vanilla JS client (no build step, no bundler) sends events and swaps the returned markup into the page. There's no client-side state store to keep in sync with the server.
It's aimed at teams already building on FastAPI, Django, Litestar, or Flask who want LiveView-style interactive widgets — dashboards, forms, admin panels, real-time counters/carts — without adding a second frontend stack.
How it works
Browser (HTMX + component-client.js)
| fetch POST /components/<name> (event + payload)
Framework Adapter (FastAPI / Django / Litestar / Flask)
|
Component Framework Core
- Lifecycle: mount → hydrate → handle_event → render → dehydrate
- Event routing: on_<event> methods, resolved by convention (sync + async)
- State: server-owned JSON, round-tripped to the client each request
- Renderer: pluggable (JinjaX, Django templates, Jinja2, or custom)
|
Backend (database / services)
Each request round-trip re-hydrates the component from the state the previous response sent down, dispatches the event to an on_<event> (or async def on_<event>) handler, re-renders, and returns HTML plus the new state. The client (component-client.js, in src/component_framework/static/) has no framework dependency — it reads data-component/data-event/data-payload attributes, POSTs the event, and reconciles the response into the DOM via Idiomorph, preserving focus, scroll position, in-flight input, and unaffected nodes' identity (including already-bound event listeners) instead of a full markup replace.
Because state round-trips through the client by default, the framework includes:
- State size guards — a 64 KB warning and a 512 KB hard limit on serialized state (
core/component.py). - Optional HMAC state signing — sign outbound state and reject tampered/unsigned state on the way back in (
docs/STATE_SIGNING.md). - Locked fields — declare state keys (roles, prices, user IDs) that the client can never influence; they're stripped from what's sent to the client and re-derived server-side on every request (
docs/LOCKED_FIELDS.md).
Framework support
| Framework | HTTP dispatch | WebSocket | SSE streaming | Renderer | Install extra |
|---|---|---|---|---|---|
| FastAPI | Yes | Yes (fastapi_websocket.py) |
Yes | JinjaX | [fastapi] |
| Django | Yes (FBV + CBV) | Yes (Channels) | Yes | Django templates, django-cotton |
[django] |
| Litestar | Yes | Yes (litestar_websocket.py) |
Yes | Jinja2 | [litestar] |
| Flask | Yes | Not yet (planned) | Not yet (planned) | Flask's Jinja2 environment | [flask] |
A few things are Django-only today even though the underlying hook is framework-agnostic in core/:
- CSRF protection — enforced via Django's
CsrfViewMiddleware. FastAPI, Litestar, and Flask have no CSRF enforcement in the adapter itself; seedocs/SECURITY_CSRF.mdfor the full per-adapter audit and integration guidance before putting cookie/session auth in front of those adapters. RateLimitMixinandCacheMixin— both wrap Django's cache framework (adapters/django_ratelimit.py,adapters/django_views.py); no FastAPI/Litestar/Flask equivalent exists yet.- Server-confirmed optimistic patches —
Component.get_optimistic_patch()is defined on the framework-agnostic base class, but only the Django adapter currently surfaces it in the response payload. Client-side optimistic prediction (data-optimisticattributes incomponent-client.js) works with every adapter, since it's pure client-side JS talking to any of them over HTTP.
Installation
# uv (recommended)
uv add "component-framework[fastapi]" # or [django] / [litestar] / [flask]
# or with pip
pip install "component-framework[fastapi]"
Pick the extra for the web framework you're on. pydantic>=2.0 is the only mandatory dependency; everything else — FastAPI, Django, Litestar, Flask, JinjaX, Channels — is optional, so you only pull in what you use.
pip install "component-framework[fastapi]" # single adapter
pip install "component-framework[fastapi,django,litestar,flask]" # several
pip install "component-framework[all]" # every adapter, plus the websockets extra
pip install "component-framework[fastapi,testing]" # + the pytest helpers, see Testing below
The quotes matter in most shells — bare brackets are glob syntax in zsh and get eaten before pip sees them.
Import an adapter whose extra you skipped and you get a deliberate error naming the fix, not a bare ModuleNotFoundError:
ImportError: 'jinjax' is not installed. Install the 'fastapi' extra:
pip install 'component-framework[fastapi]'
Extras were made optional in 0.3.0 — if you're upgrading from before that and assumed
fastapi/uvicorn/jinjaxcame by default, see CHANGELOG.md.
From a checkout
For hacking on the framework itself:
git clone https://github.com/fsecada01/component-framework.git
cd component-framework
uv pip install -e ".[dev]"
See CONTRIBUTING.md.
Quick start
FastAPI
python examples/fastapi_example.py
# http://localhost:8000
from component_framework.core import Component, registry
@registry.register("counter")
class Counter(Component):
template_name = "counter.html"
def mount(self):
self.state["count"] = 0
def on_increment(self, amount: int = 1):
self.state["count"] += amount
Wiring an existing JinjaX Catalog (most FastAPI + JinjaX apps already have one, often sharing its Jinja environment with Jinja2Templates) — pass that catalog in rather than creating a fresh one, or component templates silently lose your app's filters/globals/extensions:
from component_framework.adapters.jinjax_renderer import JinjaxRenderer
from component_framework.core.component import Component
Component.renderer = JinjaxRenderer(catalog) # the catalog your app already built
Django
cd examples/django_example
python manage.py migrate
python manage.py runserver
# http://localhost:8000
Litestar
python examples/litestar_example.py
# http://localhost:8000
Flask
python examples/flask_example.py
# http://localhost:5000
from flask import Flask
from component_framework.adapters.flask import FlaskRenderer, register_component_routes
from component_framework.core.component import Component
app = Flask(__name__)
Component.renderer = FlaskRenderer(app) # shares app.jinja_env
register_component_routes(app) # POST /components/<name>
Features
Core (framework-agnostic, core/)
- Component lifecycle with
mount/hydrate/handle_event/render/dehydrate - Async event handlers —
async def on_*, dispatched viaasync_dispatch() - Pluggable renderers — JinjaX, Django templates, Jinja2, or a custom
Renderer FormComponent— Pydantic-validated forms with field-level errors, state kept in syncSlotComponent/CompositeComponent— named slots and composing components from named children, with context propagationStreamingComponent— SSE endpoints for long-running operations with intermediate renders (FastAPI, Litestar, Django)- Permission classes —
AllowAny,IsAuthenticated,IsStaff,IsSuperuser,DjangoModelPermission; checked automatically wherever a component declarespermission_classes ComponentTestCase— mount components and dispatch events in tests without a running server;dispatch_event(),mount_component(),assert_state(),assert_rendered()
Security
- Optional HMAC-SHA256 state signing (
core/signing.py,StateSigner) — versionedcfs1.<payload>.<mac>tokens, key rotation via comma-separated keys, tampered/raw-dict state rejected with HTTP 400 locked_fields— server-trusted state keys stripped from outbound state and re-derived on every inbound request- 64 KB warn / 512 KB hard limit on serialized state size
- CSRF: enforced on Django only today — see
docs/SECURITY_CSRF.md
Django-specific
- Model binding (
DjangoModelComponent) withselect_related/prefetch_relatedand transactional saves - Class-based views with auth/permission handling and JSON (not redirect) error responses
django-cottontemplate support- Django Channels WebSocket consumer with broadcast/subscribe
RateLimitMixin(sliding-window, per-component/per-user, JSON 429s) andCacheMixin(render caching via Django's cache framework)
More examples
# Pydantic-validated form
from pydantic import BaseModel, EmailStr
from component_framework.core import FormComponent
class ContactSchema(BaseModel):
name: str
email: EmailStr
message: str
@registry.register("contact_form")
class ContactForm(FormComponent):
schema = ContactSchema
def on_submit(self):
send_email(self.validated_data)
# Composition: a parent declares named slots, children fill them
from component_framework.core import Component, registry
from component_framework.core.composition import compose
@registry.register("card")
class Card(Component):
template_name = "card.html"
slots = ["header", "body", "footer"] # omit to accept any slot name
@registry.register("cart_summary")
class CartSummary(Component):
template_name = "cart_summary.html"
# Assemble in one call. Each child's rendered HTML lands in the parent's
# template context under `slots`, keyed by slot name.
page = compose(
Card,
params={"title": "Your order"},
body=CartSummary(),
)
result = page.dispatch()
# Django model-bound component
from component_framework.adapters.django_model import DjangoModelComponent
@registry.register("order_editor")
class OrderEditor(DjangoModelComponent):
model = Order
state_fields = ["status", "notes", "total"]
select_related = ["customer"]
def on_update_status(self, status: str):
self.instance.status = status
self.save_instance()
# Testing a component without an HTTP server.
# Needs the `testing` extra: pip install "component-framework[testing]"
from component_framework.testing import ComponentTestCase
class TestCounter(ComponentTestCase):
component_class = Counter # a MockRenderer is installed per test
def test_initial_state(self):
result = self.mount()
assert result["state"]["count"] == 0
def test_increment(self):
self.mount()
self.dispatch("increment", {"amount": 5})
self.assert_state(count=5)
More worked examples: docs/examples/ecommerce.md (real-time cart), docs/examples/wizard.md (multi-step FastAPI wizard), and the runnable apps under examples/.
Documentation
Full docs (generated from docstrings via pdoc, versioned per release): fsecada01.github.io/component-framework
| Guide | Covers |
|---|---|
| Architecture spec | Core design goals and component lifecycle |
| Django implementation | Django adapter setup and patterns |
| Class-based views | Django CBV auth/permission patterns |
| State signing | HMAC state setup per adapter, key rotation |
| Locked fields | Server-trusted state fields, threat model |
| CSRF & CSWSH guide | Per-adapter CSRF audit, WebSocket hijacking guidance |
| Client-side DOM morphing | Idiomorph integration, data-no-morph escape hatch for JS-owned regions |
| E-commerce example | Real-time cart + product walkthrough |
| Multi-step wizard | FastAPI wizard recipe |
Project layout
component-framework/
├── src/component_framework/
│ ├── core/ # Framework-agnostic: component, form, state, streaming,
│ │ # registry, renderer, permissions, composition, signing
│ ├── adapters/ # fastapi[.py|_websocket.py], litestar[.py|_websocket.py],
│ │ # flask.py, django_*.py, jinjax_renderer.py
│ ├── testing.py # ComponentTestCase + pytest fixtures
│ ├── static/ # component-client.js (vanilla JS, no build step) + CSS
│ └── templatetags/ # Django template tags
├── examples/ # fastapi_example.py, fastapi_form_example.py,
│ # fastapi_wizard_example.py, litestar_example.py,
│ # flask_example.py, django_example/ (full app)
├── tests/ # 28 modules, 477 tests (pytest -q)
├── docs/ # Guides (Markdown) + pdoc-generated API site
└── pyproject.toml
Testing
pytest tests/ -q --tb=short
# or via the justfile
just test # full suite
just test-core # core/ only
just test-adapters # adapters/ only
CI (.github/workflows/ci.yml) runs the suite on Python 3.11, 3.12, 3.13, and 3.14, plus ruff and ty (Astral's type checker), on every push and pull request.
Development
just install # install with the dev extra (uv pip install -e ".[dev]")
just pre-commit-install # ruff + ty pre-commit hooks
just format / just lint / just lint-fix / just check
just docs-build / just docs-serve # pdoc, http://localhost:8000
Contributions: open an issue for anything non-trivial before starting; small fixes can go straight to a PR. See CONTRIBUTING.md.
Known limitations
- CSRF is enforced on Django only; FastAPI, Litestar, and Flask have no CSRF protection in the adapter — read
docs/SECURITY_CSRF.mdbefore putting cookie/session auth in front of them. - No origin check or handshake token on any WebSocket adapter (Cross-Site WebSocket Hijacking exposure) — see the same doc.
RateLimitMixinandCacheMixinare Django-only.- Flask has no WebSocket or SSE support yet.
- Component state must be JSON-serializable, capped at 512 KB serialized.
- WebSocket fan-out across multiple server processes requires a Redis-backed channel layer (Django Channels).
Roadmap
Beta features through 0.6.0 (permissions, rate limiting, caching, composition, testing utilities, the Litestar and Flask adapters, optional HMAC state signing, locked fields, Idiomorph-based DOM morphing with focus/scroll/in-flight-input preservation, stable list reconciliation keys, a JS-owned-region escape hatch, and a CSRF/CSWSH coverage audit) are shipped. What's next, in order, is scoped in CHANGELOG.md and tracked via GitHub milestones:
- 0.7.0b — table stakes: SPA-style navigation (history, back button), file uploads, on-blur partial validation, a verified 422 re-render convention across adapters, WebSocket reconnection/resync, Flask WebSocket/SSE parity.
- 0.8.0b — mindshare: telemetry/observability hooks, published benchmarks (none exist yet — no performance numbers are claimed anywhere else in this README), a JS interop/optimistic-command DSL.
- 1.0.0: frozen public API, a full narrative guide, a deployment guide, PyPI publishing.
Requirements
- Python 3.11+
pydantic>=2.0(only mandatory runtime dependency)
Optional extras: [fastapi] (FastAPI 0.109+, Uvicorn, JinjaX 0.41+), [django] (Django 4.2+, Channels 4.0+, channels-redis 4.1+, django-cotton 0.9+), [litestar] (Litestar 2.0+, Jinja2 3.1+), [flask] (Flask 3.0+), [websockets] (websockets 12.0+), [all].
License
MIT — see LICENSE.
Inspired by Phoenix LiveView, Laravel Livewire, Hotwire/Turbo, and HTMX.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file component_framework-0.6.0.tar.gz.
File metadata
- Download URL: component_framework-0.6.0.tar.gz
- Upload date:
- Size: 349.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bfa5fecd602e31a60e2daaba88dffaac6e75f0d338817d64cac18359672dad68
|
|
| MD5 |
4c9e7f478dc673218d44f5277aed79ff
|
|
| BLAKE2b-256 |
03ddfbec0a8d7585bfd419410e584eb951d0c6491c98257b6636e218342903ab
|
Provenance
The following attestation bundles were made for component_framework-0.6.0.tar.gz:
Publisher:
release.yml on fsecada01/component-framework
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
component_framework-0.6.0.tar.gz -
Subject digest:
bfa5fecd602e31a60e2daaba88dffaac6e75f0d338817d64cac18359672dad68 - Sigstore transparency entry: 2302597587
- Sigstore integration time:
-
Permalink:
fsecada01/component-framework@ab8901a781c4cf80eda81b01de86ffb3c214db2d -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/fsecada01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ab8901a781c4cf80eda81b01de86ffb3c214db2d -
Trigger Event:
push
-
Statement type:
File details
Details for the file component_framework-0.6.0-py3-none-any.whl.
File metadata
- Download URL: component_framework-0.6.0-py3-none-any.whl
- Upload date:
- Size: 86.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d7470fbaed84e17d2f0d6a5440e7fcb407f67f8bd5d5872770c122a1487d7fa5
|
|
| MD5 |
cccd2333764ccd28cd16a38688f4a34b
|
|
| BLAKE2b-256 |
f6f19355bb7322afe6cac392da4e1893025a317773409f8fc658e5f45fdc079d
|
Provenance
The following attestation bundles were made for component_framework-0.6.0-py3-none-any.whl:
Publisher:
release.yml on fsecada01/component-framework
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
component_framework-0.6.0-py3-none-any.whl -
Subject digest:
d7470fbaed84e17d2f0d6a5440e7fcb407f67f8bd5d5872770c122a1487d7fa5 - Sigstore transparency entry: 2302597656
- Sigstore integration time:
-
Permalink:
fsecada01/component-framework@ab8901a781c4cf80eda81b01de86ffb3c214db2d -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/fsecada01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ab8901a781c4cf80eda81b01de86ffb3c214db2d -
Trigger Event:
push
-
Statement type: