Migrate from Component.View
If one component's post() now inspects a flag to decide whether to save, archive, or preview, the HTTP verb has become a second router. Citry Events lets the initial verb-shaped handler keep working, then lets you move each operation to a named, typed handler without changing component ownership.
The mental-model shift is small: a component still owns its HTTP behavior, but the public operation is named after what the user does. Handlers return page effects such as a render, browser event, redirect, or JSON value.
The Citry examples assume the configured citry_app from Server events and these imports:
from typing import Any
from citry import Component
from citry.ext.events import ViewEvents, actions, event, get_event_url
Start with the verb compatibility route
A typical Component.View form reads the host request and constructs an HTTP response itself:
class ContactForm(Component):
class View:
def post(self, request):
name = request.POST.get("name", "stranger")
return ThankYouMessage.render_to_response(kwargs={"name": name})
For the first port, subclass ViewEvents. The HTTP method still selects post, while data is parsed and validated from the form fields. Returning a Render describes where the new fragment belongs:
class ThankYouMessage(Component):
citry = citry_app
class Kwargs:
name: str
class Slots:
pass
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"name": kwargs.name}
template = """
<p id="thank-you">
Thank you, {{ name }}!
</p>
"""
class ContactIn:
name: str = "stranger"
class ContactForm(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
class Events(ViewEvents):
def post(self, data: ContactIn):
return actions.Render(
ThankYouMessage(name=data.name),
target="#result",
swap="inner",
)
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
submit_url = self.citry.build_url(f"ext/events/e/{type(self).class_id}")
return {"submit_url": submit_url}
template = """
<form method="post" c-action="submit_url">
<input name="name">
<button type="submit">Send</button>
</form>
<div id="result"></div>
"""
The compatibility URL ends at the component class id, so a native POST can reach it without naming an event. This is the mechanical bridge used by a form that is not running JavaScript.
The native forms in this guide omit host-specific CSRF markup for brevity. They are not deployable without it: include the host's normal form token, such as Django's csrfmiddlewaretoken, because host middleware still applies. See Protect event posts from CSRF.
Only the route shape is mechanical. Update each old handler body deliberately:
- Replace
request.POSTandrequest.GETparsing with a typeddataclass. - Use the framework-neutral
requestinjectable only for facts such as the current user, headers, or the host request underrequest.native. - Return a component,
actions.Render,actions.Redirect, or JSON data rather than constructing a host response. - Keep State out of verb handlers. The token-optional compatibility route does not inject
state; stateful interactions belong in named handlers.
Name the operation after the user action
Once the form works, make its public operation submit. The same handler can serve the Citry client and a native form fallback through its per-event URL:
class NamedContactForm(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
class Events:
def submit(self, data: ContactIn):
return actions.Render(
ThankYouMessage(name=data.name),
target="#result",
swap="inner",
)
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"submit_url": self.events.url("submit")}
template = """
<form
method="post"
c-action="submit_url"
@c-submit.prevent="submit"
>
<input name="name">
<button type="submit">Send</button>
</form>
<div id="result"></div>
"""
With JavaScript, @c-submit.prevent sends the typed call and applies the render without navigation. Without JavaScript, the browser posts to submit_url and the server translates the same render result into an HTML response. That native post also needs the host's normal CSRF form token.
Use self.events.url("submit") during rendering, or get_event_url(ContactForm, "submit") outside the component. Both builders also accept query= and fragment=.
Replace query multiplexing with named handlers
A get() that branches on ?type=preview or ?type=details hides several operations behind one endpoint:
class FragmentLoader(Component):
class View:
def get(self, request):
kind = request.GET.get("type")
if kind == "preview":
return render_preview()
if kind == "details":
return render_details()
return render_page()
Declare one handler per operation. Each gets its own URL, method policy, schema, OpenAPI operation, and middleware path:
class LoadedFragment(Component):
citry = citry_app
class Kwargs:
kind: str
class Slots:
pass
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"kind": kwargs.kind}
template = """
<section class="loaded-fragment">
Loaded {{ kind }}
</section>
"""
class FragmentLoader(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
class Events:
@event(methods=("GET",))
def preview(self):
return actions.Render(
LoadedFragment(kind="preview"),
target="#fragment-target",
swap="inner",
)
@event(methods=("GET",))
def details(self):
return actions.Render(
LoadedFragment(kind="details"),
target="#fragment-target",
swap="inner",
)
template = """
<nav>
<button @c-click="preview">Preview</button>
<button @c-click="details">Details</button>
</nav>
<div id="fragment-target"></div>
"""
Returning a Citry fragment activates its component JavaScript, CSS, Alpine scope, and Events bindings through one lifecycle. There is no separate htmx or fetch activation step to maintain.
Translate dynamic HTML attributes
Citry treats an ordinary HTML attribute value as a literal string. The Django template spelling therefore leaves braces in the browser:
<!-- Wrong in a Citry template: the href contains literal braces. -->
<a href="{{ detail_url }}">Details</a>
Prefix a dynamic attribute with c- so Citry evaluates the Python expression:
<!-- Right: detail_url is evaluated during the component render. -->
<a c-href="detail_url">Details</a>
This rule applies to action, src, class, and other dynamic attributes as well as href. Text content still uses {{ expression }}.
Syntax mapping
| Component.View pattern | Citry Events spelling |
|---|---|
class View: def post(...) | Initial port: class Events(ViewEvents): def post(...) |
Several operations inside post() | One named method per operation in class Events |
request.POST.get("name") | data: ContactIn, validated from the request |
request.user | request or an authenticated _context value |
HttpResponse(component_html) | Return the component or actions.Render(...) |
HttpResponseRedirect(url) | actions.Redirect(url) |
get_component_url(...) | self.events.url(name, query=..., fragment=...) |
| Handwritten fetch or htmx target | @c-* plus actions.Render(target=...) |
| Full-page reload after mutation | Targeted render or redirect, chosen by the handler |
Keep the public boundary narrow
Citry deliberately asks each operation to declare its name and input shape. It does not revive arbitrary ORM objects from primary-key arguments, expose host request parsing as the handler contract, or treat a method name supplied in form data as a dispatcher. Those shortcuts make authorization difficult to audit. Load records from validated ids and authorize them inside every handler.
ViewEvents is a bridge, not a second permanent API. Keep it while a component is still genuinely verb-shaped. Add named methods beside its verbs, then use a plain Events class when the last compatibility handler is gone.
Finish the port
Before shipping a migrated component, check that:
- each handler has one action-shaped name;
- request fields are represented by a typed
dataclass; - every record lookup repeats authorization for the current request;
- native forms have a real
methodand dynamicc-actionURL when they need a no-JavaScript path; - render actions name the smallest target that should change; and
- State is used only by named handlers and contains the minimum durable input needed for the next call.
Continue with the Events guides for the full API, or use the Events migration parity matrix to compare the remaining Component.View behavior with the other migration paths.