BETA

Real-time E-Commerce · HTMX · WebSocket · Django

E-Commerce Live View

No React. No Redux. No build step. A real-time product page and shopping cart built entirely with server-side components, HTMX, and WebSocket — all in plain Python.

Optimistic UI WebSocket Push Permissions Rate Limiting Composition Caching Pure-Python Tests

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…"

The component-framework answer: write Python. State lives on the server. The browser sends events and receives rendered HTML. There is no client-side state machine — the server is the source of truth.

Architecture at a Glance

Browser (HTMX + component-client.js)POST /components/product/ (event, state, payload)POST /components/cart/WS /ws/components/Django Views (django_views.py) │ Permission check → rate-limit check → dispatch ▼ ProductComponent / CartComponent (pure Python)mount() / hydrate() → handle_event() → render()Django Template Engine (Jinja2 / Django templates)

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

shop/components.py Python
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
Optimistic UI in two methods. 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

shop/views.py Python
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
shop/urls.py Python
from django.urls import path
from .views import ProductComponentView

urlpatterns = [
    path(
        "components/product/<int:product_id>/",
        ProductComponentView.as_view(),
        name="product_component",
    ),
]

The Template

shop/templates/shop/product.html Django 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

shop/components.py (continued) Python
@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.

shop/consumers.py (Django Channels) Python
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.

shop/views.py (continued) Python
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.

shop/page_views.py Python
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
shop/templates/shop/product_page.html Django Template
{% 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.

shop/components.py (continued) Python
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
What unauthenticated requests receive: {"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:

shop/urls.py (decorator style) Python
from component_framework.adapters.django_ratelimit import rate_limit_component

urlpatterns = [
    path(
        "components/cart/",
        rate_limit_component("5/second")(
            AuthenticatedComponentView.as_view()
        ),
    ),
]
shop/views.py (mixin style) Python
class CartView(RateLimitMixin, AuthenticatedComponentView):
    throttle_rate = "5/second"

    def get_throttle_key(self, request, **kwargs):
        return f"cart:{request.user.pk}"
Excess requests receive HTTP 429 with a 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.

// REACT + REDUX
cart.ts TypeScript
// 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 */
}
// COMPONENT FRAMEWORK
cart/components.py Python
@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.

shop/tests/test_cart.py Python
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")
shell bash
pytest shop/tests/ -v

Running the Example

  1. Clone and install
    shellbash
    git clone https://github.com/fsecada01/component-framework
    cd component-framework/examples/django_example
    uv sync
  2. Migrate and seed
    shellbash
    python manage.py migrate
    python manage.py loaddata shop_fixtures.json
  3. Start Redis (required for WebSocket channel layer)
    shellbash
    docker run -p 6379:6379 redis:7-alpine
  4. Run the dev server
    shellbash
    python manage.py runserver
    # → http://localhost:8000/shop/product/1/
Open http://localhost:8000/shop/product/1/ — select a size, add items to the cart, and watch the cart widget update in real-time via WebSocket, with optimistic UI making every click feel instant. No page reload. No spinner. Just Python.

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