Skip to content

Repository files navigation

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.

CIDocsPython 3.11+License: MIT


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

FrameworkHTTP dispatchWebSocketSSE streamingRendererInstall extra
FastAPIYesYes (fastapi_websocket.py)YesJinjaX[fastapi]
DjangoYes (FBV + CBV)Yes (Channels)YesDjango templates, django-cotton[django]
LitestarYesYes (litestar_websocket.py)YesJinja2[litestar]
FlaskYesNot 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; see docs/SECURITY_CSRF.md for the full per-adapter audit and integration guidance before putting cookie/session auth in front of those adapters.
  • RateLimitMixin and CacheMixin — both wrap Django's cache framework (adapters/django_ratelimit.py, adapters/django_views.py); no FastAPI/Litestar/Flask equivalent exists yet.
  • Server-confirmed optimistic patchesComponent.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-optimistic attributes in component-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/jinjax came 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
fromcomponent_framework.coreimportComponent, registry@registry.register("counter")classCounter(Component):
template_name="counter.html"defmount(self):
self.state["count"] =0defon_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:

fromcomponent_framework.adapters.jinjax_rendererimportJinjaxRendererfromcomponent_framework.core.componentimportComponentComponent.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
fromflaskimportFlaskfromcomponent_framework.adapters.flaskimportFlaskRenderer, register_component_routesfromcomponent_framework.core.componentimportComponentapp=Flask(__name__)
Component.renderer=FlaskRenderer(app) # shares app.jinja_envregister_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 via async_dispatch()
  • Pluggable renderers — JinjaX, Django templates, Jinja2, or a custom Renderer
  • FormComponent — Pydantic-validated forms with field-level errors, state kept in sync
  • SlotComponent / CompositeComponent — named slots and composing components from named children, with context propagation
  • StreamingComponent — SSE endpoints for long-running operations with intermediate renders (FastAPI, Litestar, Django)
  • Permission classes — AllowAny, IsAuthenticated, IsStaff, IsSuperuser, DjangoModelPermission; checked automatically wherever a component declares permission_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) — versioned cfs1.<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) with select_related/prefetch_related and transactional saves
  • Class-based views with auth/permission handling and JSON (not redirect) error responses
  • django-cotton template support
  • Django Channels WebSocket consumer with broadcast/subscribe
  • RateLimitMixin (sliding-window, per-component/per-user, JSON 429s) and CacheMixin (render caching via Django's cache framework)

More examples

# Pydantic-validated formfrompydanticimportBaseModel, EmailStrfromcomponent_framework.coreimportFormComponentclassContactSchema(BaseModel):
name: stremail: EmailStrmessage: str@registry.register("contact_form")classContactForm(FormComponent):
schema=ContactSchemadefon_submit(self):
send_email(self.validated_data)
# Composition: a parent declares named slots, children fill themfromcomponent_framework.coreimportComponent, registryfromcomponent_framework.core.compositionimportcompose@registry.register("card")classCard(Component):
template_name="card.html"slots= ["header", "body", "footer"] # omit to accept any slot name@registry.register("cart_summary")classCartSummary(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 componentfromcomponent_framework.adapters.django_modelimportDjangoModelComponent@registry.register("order_editor")classOrderEditor(DjangoModelComponent):
model=Orderstate_fields= ["status", "notes", "total"]
select_related= ["customer"]
defon_update_status(self, status: str):
self.instance.status=statusself.save_instance()
# Testing a component without an HTTP server.# Needs the `testing` extra: pip install "component-framework[testing]"fromcomponent_framework.testingimportComponentTestCaseclassTestCounter(ComponentTestCase):
component_class=Counter# a MockRenderer is installed per testdeftest_initial_state(self):
result=self.mount()
assertresult["state"]["count"] ==0deftest_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

GuideCovers
Architecture specCore design goals and component lifecycle
Django implementationDjango adapter setup and patterns
Class-based viewsDjango CBV auth/permission patterns
State signingHMAC state setup per adapter, key rotation
Locked fieldsServer-trusted state fields, threat model
CSRF & CSWSH guidePer-adapter CSRF audit, WebSocket hijacking guidance
Client-side DOM morphingIdiomorph integration, data-no-morph escape hatch for JS-owned regions
E-commerce exampleReal-time cart + product walkthrough
Multi-step wizardFastAPI 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.md before 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.
  • RateLimitMixin and CacheMixin are 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.

About

Framework-agnostic server components with LiveView-style interactivity for Python (FastAPI / Django). HTMX-powered, no JS framework required.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages