The Scenario
A fashion retailer wants a product page that loads instantly and reflects live inventory, a shopping cart that updates the moment you click Add to Cart — with no full-page reload, no spinner, and no client-side state management — and a checkout button gated behind authentication.
The standard 2024 answer: "Build it in React, manage state with Redux (or Zustand, or Jotai...), wire up API routes, keep client and server state in sync, handle loading states, write hydration logic, set up a bundler…"
Architecture at a Glance
Key insight: state is a plain Python dict on the server. The browser sends an event name + the serialised state blob. The component hydrates from that blob, runs the event handler, then returns fresh HTML. No synchronisation logic anywhere.
ProductComponent
Renders a product card — image, name, price, stock level, size selector — and handles size selection and add-to-cart events.
The Component
from component_framework.core import Component, registry
from component_framework.core.permissions import IsAuthenticated
from component_framework.adapters.django_ratelimit import rate_limit_component
from .models import Product, Inventory
@registry.register("product")
class ProductComponent(Component):
"""
Renders a single product with real-time inventory and size selection.
params:
product_id (int): The product to display.
user (User | AnonymousUser): Injected by the view.
"""
template_name = "shop/product.html"
slots = ["reviews", "recommendations"]
def mount(self):
product_id = self.params["product_id"]
product = Product.objects.select_related("brand").get(pk=product_id)
inventory = Inventory.objects.filter(product=product).values(
"size", "stock"
)
self.state.update({
"id": product.pk,
"name": product.name,
"brand": product.brand.name,
"price": str(product.price),
"image_url": product.main_image.url,
"sizes": [{"size": r["size"], "stock": r["stock"]} for r in inventory],
"selected_size": None,
"in_cart": False,
})
def on_select_size(self, size: str):
"""User clicked a size option."""
self.state["selected_size"] = size
def on_add_to_cart(self):
"""Add selected size to cart. Requires a size to be selected first."""
if not self.state.get("selected_size"):
self.errors["size"] = "Please select a size first."
return
# Notify the cart component via WebSocket broadcast
from component_framework.core import ws_manager
import asyncio
user_id = self.params.get("user_id")
if user_id:
asyncio.create_task(
ws_manager.broadcast(
group=f"cart-{user_id}",
message={
"event": "add_item",
"payload": {
"product_id": self.state["id"],
"size": self.state["selected_size"],
},
},
)
)
self.state["in_cart"] = True
# ── Optimistic UI ────────────────────────────────────────────────────
def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
"""
Return immediate state updates the client applies before the server
responds. On error the client rolls back to the pre-patch state.
"""
if event == "select_size":
# Show selection immediately — no spinner needed.
return {"selected_size": payload.get("size")}
if event == "add_to_cart":
# Disable the button immediately to prevent double-clicks.
return {"in_cart": True}
return None
get_optimistic_patch() returns a state delta that the client applies instantly — before the server round-trip completes. If the server returns an error, the client rolls back automatically. No useOptimistic(), no thunks, no reducers.
The View
from component_framework.adapters.django_views import AuthenticatedComponentView
from component_framework.adapters.django_ratelimit import RateLimitMixin
class ProductComponentView(RateLimitMixin, AuthenticatedComponentView):
"""
Rate-limited, authenticated component endpoint.
- Unauthenticated requests → JSON 401 (HTMX handles the redirect)
- > 30 requests / min per user → JSON 429 + Retry-After header
"""
throttle_rate = "30/minute"
def get_throttle_key(self, request, **kwargs):
return f"product:{request.user.pk}"
def get_component_params(self, request, **kwargs):
params = super().get_component_params(request, **kwargs)
params["product_id"] = int(kwargs.get("product_id", 0))
return params
from django.urls import path
from .views import ProductComponentView
urlpatterns = [
path(
"components/product/<int:product_id>/",
ProductComponentView.as_view(),
name="product_component",
),
]
The Template
{# Outer element is the HTMX swap target #}
<div id="product-{{ component_id }}"
hx-target="this"
hx-swap="outerHTML">
<img src="{{ state.image_url }}" alt="{{ state.name }}" />
<div class="product-info">
<p class="brand">{{ state.brand }}</p>
<h1>{{ state.name }}</h1>
<p class="price">${{ state.price }}</p>
</div>
{# Size Selector #}
<div class="sizes">
{% for option in state.sizes %}
<button
hx-post="{% url 'product_component' state.id %}"
hx-vals='{"event": "select_size", "size": "{{ option.size }}"}'
class="size-btn {% if state.selected_size == option.size %}selected{% endif %}"
{% if option.stock == 0 %}disabled{% endif %}>
{{ option.size }}
{% if option.stock < 5 %}
<span class="low-stock">Only {{ option.stock }} left</span>
{% endif %}
</button>
{% endfor %}
</div>
{% if errors.size %}<p class="error">{{ errors.size }}</p>{% endif %}
{# Slot: reviews injected by the parent page view #}
{% if slots.reviews %}
<section class="reviews">{{ slots.reviews|safe }}</section>
{% endif %}
{# Add to Cart #}
<button
hx-post="{% url 'product_component' state.id %}"
hx-vals='{"event": "add_to_cart"}'
{% if state.in_cart or not state.selected_size %}disabled{% endif %}>
{% if state.in_cart %}Added to Cart ✓{% else %}Add to Cart{% endif %}
</button>
</div>
CartComponent
Renders the current cart contents, handles quantity changes and item removal, and updates in real-time via WebSocket push from ProductComponent.
The Component
@registry.register("cart")
class CartComponent(Component):
"""
Shopping cart — persists in server state, updates via WebSocket events.
State shape:
items: list[{product_id, name, size, price, qty}]
total: str (Decimal as string for JSON safety)
"""
template_name = "shop/cart.html"
def mount(self):
self.state.update({"items": [], "total": "0.00"})
def _recalculate_total(self):
from decimal import Decimal
total = sum(
Decimal(item["price"]) * item["qty"]
for item in self.state["items"]
)
self.state["total"] = str(total)
def on_add_item(self, product_id: int, size: str):
"""Add an item or increment its quantity if already present."""
from .models import Product
for item in self.state["items"]:
if item["product_id"] == product_id and item["size"] == size:
item["qty"] += 1
self._recalculate_total()
return
product = Product.objects.get(pk=product_id)
self.state["items"].append({
"product_id": product_id,
"name": product.name,
"size": size,
"price": str(product.price),
"qty": 1,
"image_url": product.thumbnail.url,
})
self._recalculate_total()
def on_remove_item(self, product_id: int, size: str):
self.state["items"] = [
item for item in self.state["items"]
if not (item["product_id"] == product_id and item["size"] == size)
]
self._recalculate_total()
def on_set_qty(self, product_id: int, size: str, qty: int):
if qty <= 0:
self.on_remove_item(product_id, size)
return
for item in self.state["items"]:
if item["product_id"] == product_id and item["size"] == size:
item["qty"] = qty
self._recalculate_total()
# ── Optimistic UI ────────────────────────────────────────────────────
def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
if event == "remove_item":
# Immediately hide the row before the server confirms deletion.
product_id = payload.get("product_id")
size = payload.get("size")
remaining = [
item for item in self.state.get("items", [])
if not (item["product_id"] == product_id and item["size"] == size)
]
return {"items": remaining}
return None
Real-Time WebSocket Updates
When ProductComponent.on_add_to_cart() fires, it broadcasts a message to the user's cart channel. The cart consumer listens and pushes an updated render to the browser — all without the user doing anything.
from component_framework.core import ws_manager
class CartConsumer(ws_manager.ComponentConsumer):
"""
WebSocket consumer that keeps the cart widget live.
The parent class handles:
- group subscription (based on user id)
- dispatching incoming messages as component events
- pushing rendered HTML back to the client
"""
component_name = "cart"
async def websocket_connect(self, message):
user = self.scope["user"]
if not user.is_authenticated:
await self.close()
return
await self.channel_layer.group_add(
f"cart-{user.pk}", self.channel_name
)
await super().websocket_connect(message)
Caching the Product Page
The cart widget must never be cached — it's user-specific and mutable. But the product listing page surrounding it is an excellent caching target.
class CachedProductPageView(CacheMixin, ComponentView):
"""
Cache product page HTML for 5 minutes per product.
Event requests (add_to_cart, select_size) bypass the cache automatically.
"""
cache_timeout = 300
def get_cache_key(self, name, params, state):
product_id = params.get("product_id", "unknown")
return f"product-page:{product_id}"
Page Composition
The full product page composes ProductComponent with a ReviewsComponent injected into the "reviews" slot. compose() wires the slot and renders both components in one call.
from django.views.generic import TemplateView
from component_framework.core import compose, registry
class ProductPageView(TemplateView):
template_name = "shop/product_page.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
product_id = self.kwargs["product_id"]
reviews_cls = registry.get("reviews")
product_cls = registry.get("product")
# compose() wires the slot and renders both components in one call
product = compose(
product_cls,
params={"product_id": product_id, "user": self.request.user},
reviews=reviews_cls(product_id=product_id),
)
result = product.dispatch()
context["product_html"] = result["html"]
context["product_state"] = result["state"]
return context
{% extends "base.html" %}
{% block content %}
<div class="product-layout">
<div class="product-main">
{{ product_html|safe }}
</div>
<aside class="cart-sidebar">
{# Cart widget — synced via WebSocket, initially server-rendered #}
<div id="cart-widget"
hx-ext="ws"
ws-connect="/ws/cart/">
</div>
</aside>
</div>
{% endblock %}
Permission Gating
The checkout component requires authentication. Setting permission_classes on the component class means every view that serves it — FBV or CBV — enforces the check automatically. Denied requests return JSON 401/403, never a redirect that would break HTMX.
from component_framework.core.permissions import IsAuthenticated
@registry.register("checkout")
class CheckoutComponent(Component):
permission_classes = [IsAuthenticated]
template_name = "shop/checkout.html"
def mount(self):
user = self.params["user"]
self.state["email"] = user.email
self.state["address"] = user.profile.shipping_address
def on_place_order(self, payment_token: str):
# ... process payment, create Order record ...
self.state["confirmed"] = True
{"error": "Authentication required"} with HTTP 401. HTMX's htmx:responseError event handles the redirect to login on the client side — no server-side redirect needed.
Rate Limiting the Cart
Protect the add-to-cart endpoint from accidental double-submission or abuse. Two equivalent approaches — a decorator or a mixin:
from component_framework.adapters.django_ratelimit import rate_limit_component
urlpatterns = [
path(
"components/cart/",
rate_limit_component("5/second")(
AuthenticatedComponentView.as_view()
),
),
]
class CartView(RateLimitMixin, AuthenticatedComponentView):
throttle_rate = "5/second"
def get_throttle_key(self, request, **kwargs):
return f"cart:{request.user.pk}"
Retry-After header. The JS client handles this gracefully and rolls back any optimistic state automatically.
React vs Component Framework
Here is the same cart update in a React + Redux stack compared to component-framework. Three separate concepts (async thunk, reducer, optimistic hook) across multiple files, plus TypeScript types, plus a store provider, plus a bundler — versus one Python class.
// 1. async thunk
const addToCart = createAsyncThunk(
'cart/addItem',
async (item, { dispatch }) => {
const r = await fetch('/api/cart/', {
method: 'POST',
body: JSON.stringify(item),
headers: { 'X-CSRFToken': getCsrf() },
});
return r.json();
}
);
// 2. reducer
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: '0.00' },
extraReducers: (builder) => {
builder
.addCase(addToCart.pending, s => {
s.status = 'loading'
})
.addCase(addToCart.fulfilled, (s, a) => {
s.items = a.payload.items;
s.total = a.payload.total;
s.status = 'idle';
})
.addCase(addToCart.rejected, s => {
s.status = 'error'
});
},
});
// 3. optimistic hook (React 19)
function CartButton({ item }) {
const [optimisticItems, addOptimistic] =
useOptimistic(items,
(state, newItem) =>
[...state, { ...newItem, pending: true }]
);
/* + store provider, types, bundler cfg */
}
@registry.register("cart")
class CartComponent(Component):
def on_add_item(self,
product_id: int,
size: str):
"""Add item or increment qty."""
for item in self.state["items"]:
if (item["product_id"] == product_id
and item["size"] == size):
item["qty"] += 1
self._recalculate_total()
return
self.state["items"].append({
"product_id": product_id,
"size": size,
"qty": 1,
})
self._recalculate_total()
def get_optimistic_patch(self,
event: str,
payload: dict
) -> dict | None:
if event == "add_item":
return {
"items":
self.state["items"]
+ [{**payload, "qty": 1}]
}
return None
# That's it. pytest covers the rest.
| Concern | React + Redux | Component Framework |
|---|---|---|
| State location | useState / Redux store in browser |
self.state dict on the server |
| Sync strategy | REST/GraphQL polling or WS events | Built-in server state — no sync needed |
| Optimistic UI | useOptimistic() + rollback logic |
get_optimistic_patch() → auto rollback |
| Auth gating | Middleware + useSession() hook |
permission_classes = [IsAuthenticated] |
| Rate limiting | Custom middleware or API gateway | RateLimitMixin / @rate_limit_component |
| Caching | useMemo + server-side cache |
CacheMixin — event requests bypass |
| Build step | webpack / Vite required | None |
| JS payload | 150–400 KB gzipped | ~5 KB (htmx + component-client.js) |
| Test tooling | Jest + Storybook + RTL | pytest — pure Python, no browser |
Testing
Because components are pure Python, the full cart behaviour is testable without Django, HTMX, or a running server. ComponentTestCase provides make_component() which mounts the component in a test context.
from component_framework.testing import ComponentTestCase, MockRenderer
from shop.components import CartComponent
class TestCartComponent(ComponentTestCase):
def setUp(self):
CartComponent.renderer = MockRenderer()
def test_add_item(self):
cart = self.make_component(CartComponent)
cart.mount()
cart.on_add_item(product_id=42, size="M")
self.assertEqual(len(cart.state["items"]), 1)
self.assertEqual(cart.state["items"][0]["qty"], 1)
def test_add_same_item_increments_qty(self):
cart = self.make_component(CartComponent)
cart.mount()
cart.on_add_item(product_id=42, size="M")
cart.on_add_item(product_id=42, size="M")
self.assertEqual(len(cart.state["items"]), 1)
self.assertEqual(cart.state["items"][0]["qty"], 2)
def test_remove_item(self):
cart = self.make_component(CartComponent)
cart.mount()
cart.on_add_item(product_id=42, size="M")
cart.on_remove_item(product_id=42, size="M")
self.assertEqual(cart.state["items"], [])
def test_optimistic_patch_remove(self):
cart = self.make_component(CartComponent)
cart.mount()
cart.on_add_item(product_id=42, size="M")
patch = cart.get_optimistic_patch("remove_item", {"product_id": 42, "size": "M"})
self.assertEqual(patch["items"], [])
def test_total_recalculates(self):
cart = self.make_component(CartComponent)
cart.mount()
cart.state["items"] = [{
"product_id": 1, "size": "S",
"price": "29.99", "qty": 3,
}]
cart._recalculate_total()
self.assertEqual(cart.state["total"], "89.97")
pytest shop/tests/ -v
Running the Example
-
Clone and install
shellbashgit clone https://github.com/fsecada01/component-framework cd component-framework/examples/django_example uv sync -
Migrate and seed
shellbashpython manage.py migrate python manage.py loaddata shop_fixtures.json -
Start Redis (required for WebSocket channel layer)
shellbashdocker run -p 6379:6379 redis:7-alpine -
Run the dev server
shellbashpython manage.py runserver # → http://localhost:8000/shop/product/1/
Summary
| Feature | Component | How |
|---|---|---|
| Real-time render | ProductComponent | HTMX hx-post + server re-render on every event |
| Optimistic UI | get_optimistic_patch() | Pre-dispatch state patch — JS rolls back on error |
| WebSocket push | CartComponent | ws_manager.broadcast() → Channels → browser |
| Auth gating | CheckoutComponent | permission_classes = [IsAuthenticated] |
| Rate limiting | Cart endpoint | RateLimitMixin / @rate_limit_component |
| Caching | Product page view | CacheMixin — event requests bypass automatically |
| Composition | ProductPageView | compose(product, reviews=reviews_instance) |
| Testing | All components | Pure Python — pytest, MockRenderer, ComponentTestCase |