The Scenario
A SaaS company's support team handles hundreds of tickets a day. They need a real-time ticket queue where new tickets appear the moment a customer submits them — no polling, no page refresh. Each ticket must flow through a well-defined state machine: open → assigned → pending_customer → resolved, with invalid transitions rejected server-side. Ticket submission must be rate-limited (5 per hour per user) to prevent spam and accidental duplicate submissions. The agent layout uses a slot-based composition — a persistent sidebar showing agent availability alongside the main ticket panel.
The standard answer involves React, a socket.io server, a REST API for ticket CRUD, a separate state-machine library, middleware for rate limiting, a context provider for agent state, and Storybook for component testing. The team considered this path carefully.
Architecture at a Glance
Key insight: ticket status, assignment, and SLA breach data are all stored in self.state on the server. The browser never owns a copy of the state machine — it simply sends event names and receives fresh HTML fragments. Invalid transitions are caught in the handler before any database write occurs.
TicketQueueComponent — Live Queue
Renders a paginated, filterable list of open tickets. New tickets are pushed via WebSocket and prepended to the list instantly. Tickets that have exceeded their SLA deadline are flagged with a computed sla_breached field in state — no client-side logic required.
The Component
from datetime import timedelta
from django.utils import timezone
from component_framework.core import Component, registry
from .models import Ticket
@registry.register("ticket_queue")
class TicketQueueComponent(Component):
"""
Live ticket queue for support agents.
State shape:
tickets: list[TicketSummary] paginated, newest-first
status_filter: str "open" | "assigned" | "all"
page: int
has_more: bool
"""
template_name = "support/ticket_queue.html"
PAGE_SIZE = 20
SLA_HOURS = 8
def mount(self):
self.state.update({
"tickets": [],
"status_filter": "open",
"page": 1,
"has_more": False,
})
self._load_tickets()
def _load_tickets(self):
sla_deadline = timezone.now() - timedelta(hours=self.SLA_HOURS)
qs = (
Ticket.objects
.select_related("assignee", "customer")
.order_by("-created_at")
)
status = self.state["status_filter"]
if status != "all":
qs = qs.filter(status=status)
page = self.state["page"]
offset = (page - 1) * self.PAGE_SIZE
batch = qs[offset : offset + self.PAGE_SIZE + 1]
has_more = len(batch) > self.PAGE_SIZE
self.state["tickets"] = [
{
"id": t.pk,
"subject": t.subject,
"customer": t.customer.get_full_name(),
"status": t.status,
"assignee": t.assignee.get_full_name() if t.assignee else None,
"created_at": t.created_at.isoformat(),
"sla_breached": t.created_at < sla_deadline and t.status != "resolved",
}
for t in batch[: self.PAGE_SIZE]
]
self.state["has_more"] = has_more
def on_filter_status(self, status: str):
"""Switch the status filter and reset to page 1."""
allowed = {"open", "assigned", "pending_customer", "resolved", "all"}
if status not in allowed:
self.errors["filter"] = f"Unknown status: {status}"
return
self.state["status_filter"] = status
self.state["page"] = 1
self._load_tickets()
def on_assign_to_me(self, ticket_id: int):
"""Claim an unassigned ticket directly from the queue."""
agent = self.params["user"]
ticket = Ticket.objects.get(pk=ticket_id, status="open")
ticket.assignee = agent
ticket.status = "assigned"
ticket.save(update_fields=["assignee", "status"])
# Reflect immediately in local state without re-querying
for t in self.state["tickets"]:
if t["id"] == ticket_id:
t["status"] = "assigned"
t["assignee"] = agent.get_full_name()
break
def on_load_more(self):
"""Paginate — append next page of tickets to existing list."""
if not self.state["has_more"]:
return
self.state["page"] += 1
self._load_tickets()
WebSocket Push — New Ticket Arrives
When a customer submits a ticket, QuickReplyComponent (or the public submission form) broadcasts a new_ticket event to the agents' shared channel. The queue consumer receives it and prepends the ticket to state["tickets"] without touching the database again.
from component_framework.core import ws_manager
from django.utils import timezone
class QueueConsumer(ws_manager.ComponentConsumer):
"""
WebSocket consumer: subscribes each authenticated agent to the
shared 'support-queue' group. Incoming 'new_ticket' messages
prepend the ticket summary to local state and re-render.
"""
component_name = "ticket_queue"
async def websocket_connect(self, message):
user = self.scope["user"]
if not user.is_authenticated or not user.is_staff:
await self.close()
return
await self.channel_layer.group_add("support-queue", self.channel_name)
await super().websocket_connect(message)
async def handle_new_ticket(self, ticket_data: dict):
# Prepend to state and push updated HTML to this agent's browser
self.component.state["tickets"].insert(0, ticket_data)
html = await self.render_component()
await self.send_html(html)
sla_breached boolean is computed in _load_tickets() each time the queue renders. The template simply adds a CSS class when the flag is true. The browser never does date arithmetic.
TicketDetailComponent — State Machine
Wraps a single Ticket model. Transitions between statuses are validated by a guard dict — invalid transitions raise ComponentError before any database write. OptimisticMixin means clicking "Resolve" immediately updates the badge in the browser while the server confirms.
The Component
from pydantic import BaseModel, field_validator
from component_framework.core import ComponentError
from component_framework.adapters.django_model import DjangoModelComponent
from component_framework.core.composition import OptimisticMixin
class TicketState(BaseModel):
"""Validated state shape for TicketDetailComponent."""
ticket_id: int
subject: str
body: str
status: str
assignee_id: int | None
customer: str
created_at: str
@field_validator("status")
def status_must_be_valid(cls, v):
allowed = {"open", "assigned", "pending_customer", "resolved"}
if v not in allowed:
raise ValueError(ff"Invalid status: {v}")
return v
# State machine transition guard
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
"open": {"assigned"},
"assigned": {"pending_customer", "resolved"},
"pending_customer": {"assigned", "resolved"},
"resolved": set(),
}
@registry.register("ticket_detail")
class TicketDetailComponent(OptimisticMixin, DjangoModelComponent):
"""
Single-ticket view with server-side state machine transitions.
params:
ticket_id (int): PK of the ticket to render.
user (User): Injected by view for permission checks.
"""
model = Ticket
state_fields = ["subject", "body", "status", "assignee_id"]
template_name = "support/ticket_detail.html"
def mount(self):
ticket_id = self.params["ticket_id"]
t = Ticket.objects.select_related("customer", "assignee").get(pk=ticket_id)
self.state.update({
"ticket_id": t.pk,
"subject": t.subject,
"body": t.body,
"status": t.status,
"assignee_id": t.assignee_id,
"customer": t.customer.get_full_name(),
"created_at": t.created_at.isoformat(),
})
def on_transition(self, to_status: str):
"""
Advance the ticket through the state machine.
Raises ComponentError for invalid transitions — no DB write occurs.
"""
current = self.state["status"]
if to_status not in ALLOWED_TRANSITIONS.get(current, set()):
raise ComponentError(
ff"Transition '{current}' → '{to_status}' is not permitted."
)
ticket = Ticket.objects.get(pk=self.state["ticket_id"])
ticket.status = to_status
if to_status == "assigned" and not ticket.assignee_id:
ticket.assignee = self.params["user"]
self.state["assignee_id"] = self.params["user"].pk
ticket.save(update_fields=["status", "assignee"])
self.state["status"] = to_status
# ── Optimistic UI ────────────────────────────────────────────────────
def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
"""
Flash the new status badge immediately. The server validates the
transition and rolls back if the guard rejects it.
"""
if event == "transition":
return {"status": payload.get("to_status")}
return None
ComponentError returns HTTP 400 with a JSON body so HTMX can show a toast, and no state is mutated.
The View
from component_framework.adapters.django_views import AuthenticatedComponentView
from component_framework.core.permissions import IsStaff
class TicketDetailView(AuthenticatedComponentView):
"""
Only staff agents may manage tickets.
Customers access a read-only, separately-permissioned view.
"""
permission_classes = [IsStaff]
def get_component_params(self, request, **kwargs):
params = super().get_component_params(request, **kwargs)
params["ticket_id"] = int(kwargs["ticket_id"])
return params
from django.urls import path
from .views import TicketDetailView, TicketQueueView, QuickReplyView
urlpatterns = [
path("components/queue/", TicketQueueView.as_view(), name="support_queue"),
path("components/ticket/<int:ticket_id>/", TicketDetailView.as_view(), name="support_ticket"),
path("components/reply/<int:ticket_id>/", QuickReplyView.as_view(), name="support_reply"),
]
The Template
{# Outer element is the HTMX swap target #}
<div id="ticket-{{ component_id }}"
hx-target="this"
hx-swap="outerHTML">
<header class="ticket-header">
<h2>{{ state.subject }}</h2>
<span class="status-badge status--{{ state.status }}">
{{ state.status }}
</span>
</header>
<p class="ticket-meta">
From {{ state.customer }} · opened {{ state.created_at }}
</p>
<div class="ticket-body">{{ state.body }}</div>
{# Transition buttons — only rendered for valid next states #}
<div class="ticket-actions">
{% if state.status == "open" %}
<button
hx-post="{% url 'support_ticket' state.ticket_id %}"
hx-vals='{"event": "transition", "to_status": "assigned"}'>
Assign to Me
</button>
{% endif %}
{% if state.status == "assigned" %}
<button
hx-post="{% url 'support_ticket' state.ticket_id %}"
hx-vals='{"event": "transition", "to_status": "pending_customer"}'>
Awaiting Customer
</button>
<button
hx-post="{% url 'support_ticket' state.ticket_id %}"
hx-vals='{"event": "transition", "to_status": "resolved"}'
class="btn-resolve">
Resolve Ticket
</button>
{% endif %}
{% if state.status == "pending_customer" %}
<button
hx-post="{% url 'support_ticket' state.ticket_id %}"
hx-vals='{"event": "transition", "to_status": "resolved"}'
class="btn-resolve">
Resolve Ticket
</button>
{% endif %}
{% if state.status == "resolved" %}
<p class="resolved-notice">This ticket is resolved.</p>
{% endif %}
</div>
</div>
AgentDashboardComponent — Availability
The dashboard is a CompositeComponent that composes two sub-components into a two-panel layout: AgentStatusComponent fills the "sidebar" slot (showing each agent's current availability), and TicketQueueComponent fills the "main" slot. When an agent changes their status, a WebSocket broadcast updates the sidebar for every other agent simultaneously.
The Component
from component_framework.core.composition import CompositeComponent, SlotComponent
@registry.register("agent_status")
class AgentStatusComponent(SlotComponent):
"""
Sidebar listing all online agents and their current availability.
State shape:
agents: list[{id, name, status, avatar_url}]
my_status: str "available" | "busy" | "away"
"""
template_name = "support/agent_status.html"
def mount(self):
from django.contrib.auth import get_user_model
User = get_user_model()
agents = User.objects.filter(is_staff=True, is_active=True).select_related("profile")
me = self.params["user"]
self.state.update({
"agents": [
{
"id": a.pk,
"name": a.get_full_name(),
"status": a.profile.support_status,
"avatar_url": a.profile.avatar_url,
}
for a in agents
],
"my_status": me.profile.support_status,
})
def on_set_status(self, status: str):
"""Agent changes their own availability. Broadcast to all peers."""
allowed = {"available", "busy", "away"}
if status not in allowed:
return
me = self.params["user"]
me.profile.support_status = status
me.profile.save(update_fields=["support_status"])
self.state["my_status"] = status
import asyncio
from component_framework.core import ws_manager
asyncio.create_task(
ws_manager.broadcast(
group="support-queue",
message={
"event": "agent_status_changed",
"payload": {"agent_id": me.pk, "status": status},
},
)
)
@registry.register("agent_dashboard")
class AgentDashboardComponent(CompositeComponent):
"""
Two-panel layout composed from AgentStatusComponent (sidebar slot)
and TicketQueueComponent (main slot).
"""
template_name = "support/agent_dashboard.html"
slots = ["sidebar", "main"]
def mount(self):
user = self.params["user"]
self.state["title"] = "Support Dashboard"
self.fill_slot(
"sidebar",
AgentStatusComponent(params={"user": user}),
)
self.fill_slot(
"main",
TicketQueueComponent(params={"user": user}),
)
The Composition Template
{% extends "support/base.html" %}
{% block content %}
<div class="dashboard-layout">
{# Sidebar slot — AgentStatusComponent renders here #}
<aside class="dashboard-sidebar">
{% component_slot "sidebar" %}
</aside>
{# Main slot — TicketQueueComponent renders here #}
<main class="dashboard-main">
<h1>{{ state.title }}</h1>
{% component_slot "main" %}
</main>
</div>
{% endblock %}
QueueConsumer.
QuickReplyComponent — Rate-Limited Submission
A FormComponent for submitting a reply to a ticket. RateLimitMixin enforces 5 submissions per hour per user. The Pydantic ReplySchema validates content length and strips HTML. On successful submission, a WebSocket broadcast delivers the reply to the customer's browser.
The Component
from pydantic import BaseModel, field_validator, constr
from component_framework.core.form import FormComponent
from .models import TicketReply
class ReplySchema(BaseModel):
"""Validated reply payload."""
content: constr(min_length=10, max_length=4000, strip_whitespace=True)
@field_validator("content")
def strip_html_tags(cls, v: str) -> str:
import html
return html.escape(v)
@registry.register("quick_reply")
class QuickReplyComponent(FormComponent):
"""
Rate-limited reply form for support agents.
params:
ticket_id (int): Ticket to reply to.
user (User): Sending agent.
"""
schema = ReplySchema
template_name = "support/quick_reply.html"
def mount(self):
self.state.update({
"ticket_id": self.params["ticket_id"],
"submitted": False,
"content": "",
})
def on_submit(self):
"""
Validate, persist the reply, then push it to the customer via WebSocket.
Pydantic validation errors are collected in self.errors automatically.
"""
if not self.validate():
return
agent = self.params["user"]
ticket_id = self.state["ticket_id"]
reply = TicketReply.objects.create(
ticket_id=ticket_id,
author=agent,
content=self.validated_data.content,
)
# Push reply to the customer's browser
import asyncio
from component_framework.core import ws_manager
asyncio.create_task(
ws_manager.broadcast(
group=f"ticket-customer-{ticket_id}",
message={
"event": "new_reply",
"payload": {
"reply_id": reply.pk,
"author": agent.get_full_name(),
"content": reply.content,
},
},
)
)
self.state["submitted"] = True
self.state["content"] = ""
Rate Limiting the Reply Endpoint
The view applies RateLimitMixin with a per-user throttle of 5 submissions per hour. The throttle key is scoped to reply:{user_id} so different agents have independent budgets. Excess requests receive HTTP 429 with a Retry-After header; optimistic state is rolled back automatically by the client library.
from component_framework.adapters.django_ratelimit import RateLimitMixin
class QuickReplyView(RateLimitMixin, AuthenticatedComponentView):
"""
Rate-limited reply endpoint.
- Staff only (permission_classes on component)
- 5 replies per hour per agent (RateLimitMixin)
- Excess → HTTP 429 + Retry-After header
"""
permission_classes = [IsStaff]
throttle_rate = "5/hour"
def get_throttle_key(self, request, **kwargs):
return ff"reply:{request.user.pk}"
def get_component_params(self, request, **kwargs):
params = super().get_component_params(request, **kwargs)
params["ticket_id"] = int(kwargs["ticket_id"])
return params
React vs Component Framework
Here is the state machine transition handler — the same business logic in React and in component-framework. The React version requires useReducer, action type constants, a dispatch function, and a context provider wired through the component tree. The component-framework version is 8 lines.
// 1. Action types
type Action =
| { type: 'TRANSITION'; to: Status }
| { type: 'ERROR'; msg: string };
// 2. Reducer with guard
function ticketReducer(state, action) {
if (action.type === 'TRANSITION') {
const allowed = TRANSITIONS[state.status];
if (!allowed?.includes(action.to)) {
return {...state,
error: `Bad transition: ${action.to}`};
}
return {...state, status: action.to};
}
return state;
}
// 3. Component
function TicketDetail({ ticketId }) {
const [state, dispatch] = useReducer(
ticketReducer, initialState
);
const transition = async (to) => {
dispatch({ type: 'TRANSITION', to });
try {
await apiTransition(ticketId, to);
} catch {
dispatch({ type: 'ERROR', msg: 'Failed' });
}
};
/* + TicketContext.Provider, types, bundler */
}
# Transition guard — plain dict
ALLOWED_TRANSITIONS = {
"open": {"assigned"},
"assigned": {"pending_customer",
"resolved"},
"pending_customer": {"assigned",
"resolved"},
"resolved": set(),
}
def on_transition(self, to_status: str):
current = self.state["status"]
if to_status not in \
ALLOWED_TRANSITIONS.get(current, set()):
raise ComponentError(
ff"'{current}' → '{to_status}' forbidden"
)
ticket = Ticket.objects.get(
pk=self.state["ticket_id"]
)
ticket.status = to_status
ticket.save(update_fields=["status"])
self.state["status"] = to_status
# That's it. pytest covers the rest.
| Concern | React + Context/Redux | Component Framework |
|---|---|---|
| State machine | useReducer + action types + guard logic |
on_transition() + ALLOWED_TRANSITIONS dict |
| Invalid transitions | Client-side guard + server re-validation | Server-only — ComponentError → HTTP 400 |
| Real-time queue | socket.io + Redux dispatch on message | ws_manager.broadcast() → consumer prepend |
| Rate limiting | API gateway / custom middleware | RateLimitMixin(throttle_rate="5/hour") |
| Composition | Context providers + prop drilling | CompositeComponent + fill_slot() |
| Optimistic UI | useOptimistic hook + rollback on error |
get_optimistic_patch() → auto rollback |
| Input validation | Zod / Yup schema (client) + server re-check | Pydantic — single schema, server only |
| Auth gating | Route guards + API middleware | permission_classes = [IsStaff] |
| Build step | webpack / Vite required | None |
| Test tooling | Jest + RTL + mock socket server | pytest — pure Python, no browser |
Testing
All component behaviour — state machine guards, rate-limit rejection, and optimistic patches — is testable with plain Python. No browser, no running server, no mocking of React internals or socket connections required.
from component_framework.testing import ComponentTestCase, MockRenderer
from component_framework.core import ComponentError
from support.components import TicketDetailComponent, QuickReplyComponent
class TestTicketStateMachine(ComponentTestCase):
def setUp(self):
TicketDetailComponent.renderer = MockRenderer()
def _make_ticket(self, status="open"):
c = self.make_component(TicketDetailComponent)
c.state = {
"ticket_id": 1,
"subject": "Login broken",
"body": "Cannot log in since update.",
"status": status,
"assignee_id": None,
}
return c
def test_valid_transition_open_to_assigned(self):
c = self._make_ticket("open")
c.on_transition("assigned")
self.assertEqual(c.state["status"], "assigned")
def test_invalid_transition_open_to_resolved_raises(self):
c = self._make_ticket("open")
with self.assertRaises(ComponentError):
c.on_transition("resolved")
def test_resolved_ticket_accepts_no_transitions(self):
c = self._make_ticket("resolved")
for target in ["open", "assigned", "pending_customer"]:
with self.assertRaises(ComponentError):
c.on_transition(target)
def test_optimistic_patch_contains_status(self):
c = self._make_ticket("assigned")
patch = c.get_optimistic_patch(
"transition", {"to_status": "resolved"}
)
self.assertEqual(patch["status"], "resolved")
def test_optimistic_patch_is_none_for_unknown_event(self):
c = self._make_ticket("open")
patch = c.get_optimistic_patch("unknown_event", {})
self.assertIsNone(patch)
class TestQuickReplyValidation(ComponentTestCase):
def setUp(self):
QuickReplyComponent.renderer = MockRenderer()
def test_short_content_fails_validation(self):
c = self.make_component(QuickReplyComponent, params={"ticket_id": 1})
c.mount()
c.state["content"] = "Too short"
result = c.validate()
self.assertFalse(result)
self.assertIn("content", c.errors)
def test_valid_content_passes(self):
c = self.make_component(QuickReplyComponent, params={"ticket_id": 1})
c.mount()
c.state["content"] = "Thank you for reaching out. I have investigated the issue and applied a fix."
result = c.validate()
self.assertTrue(result)
self.assertEqual(c.errors, {})
pytest support/tests/ -v
on_transition() is pure Python before any ORM call. By pre-loading self.state directly, tests exercise the full guard and optimistic-patch path without migrations, fixtures, or mocking Django's ORM.
Running the Example
-
Clone and install the Django extras
shellbashgit clone https://github.com/fsecada01/component-framework cd component-framework/examples/django_example uv sync --extra django -
Apply migrations and create a staff superuser
shellbashpython manage.py migrate python manage.py createsuperuser -
Configure Django Channels in ASGI with Redis as the channel layer
settings.py (excerpt)PythonCHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": {"hosts": [("127.0.0.1", 6379)]}, } } -
Start Redis (required for WebSocket channel layer)
shellbashdocker run -p 6379:6379 redis:7-alpine -
Run the ASGI dev server
shellbashpython manage.py runserver # → http://localhost:8000/support/dashboard/
Summary
| Feature | Component / Mechanism | How |
|---|---|---|
| Real-time queue | TicketQueueComponent | WebSocket Push via ws_manager.broadcast() → consumer prepend |
| State machine | on_transition guard | ALLOWED_TRANSITIONS dict + ComponentError on violation |
| Rate limiting | QuickReplyComponent | RateLimitMixin(throttle_rate="5/hour") |
| Composition | AgentDashboardComponent | CompositeComponent + SlotComponent + fill_slot() |
| Optimistic feedback | TicketDetailComponent | OptimisticMixin + get_optimistic_patch() → auto rollback |
| Input validation | ReplySchema | Pydantic BaseModel — server-only, no client duplicate |
| Auth gating | All agent views | permission_classes = [IsStaff] → JSON 401/403 |
| Testing | All components | Pure Python — pytest, MockRenderer, ComponentTestCase |