Migrate from Tetra
If a Tetra component's encrypted state token, Alpine object, Python instance, and template all feel like one object, deployment and authorization concerns can become hard to separate. Citry keeps the convenient server-method call but splits the contract into render inputs, strict JSON State, typed event data, and a closed list of browser actions.
Alpine remains available and is bundled automatically. Citry owns the component and slot graph, then attaches Alpine scopes to that graph. You still use ordinary Alpine directives and expressions without making an Alpine x-data object the source of component identity.
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 actions, event
Move public attributes and methods to State and Events
A Tetra counter exposes a public attribute and a decorated server method. The method automatically re-renders the component:
from tetra import Component, public
class Counter(Component):
count = public(0)
@public.debounce(200)
def increment(self, amount=1):
self.count += amount
template = """
<button
{% ... attrs %}
@click="increment(1)"
x-text="count"
></button>
"""
In Citry, the browser-visible value is a State field and the callable method lives in Events. The handler returns the fresh component render and a JSON value for callers that await it:
class StepIn:
amount: int = 1
class Counter(Component):
citry = citry_app
class Kwargs:
count: int = 0
class Slots:
pass
class State(Kwargs):
pass
class Events:
@event(debounce=200)
def increment(self, data: StepIn, state):
state.count += data.amount
return [
Counter(count=state.count),
actions.Data({"count": state.count}),
]
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"count": kwargs.count}
template = """
<button @c-click="increment({ amount: 1 })">
Count: {{ count }}
</button>
"""
js = """
$component(({ scope, sendEvent }) => {
scope.addOne = async () => {
const result = await sendEvent("increment", { amount: 1 });
return result.count;
};
});
"""
@event(debounce=200) supplies the handler's default client-side debounce interval. A binding can override it with its own .debounce.<time> modifier. Debouncing reduces request frequency but is not a server-side rate limit; enforce authorization and rate limits on the server. The decorator default applies to declarative @c-* and :c-* bindings. Direct sendEvent(...) calls do not consult it, so debounce programmatic calls in JavaScript when they need the same behavior.
State is signed strict JSON. It is visible in page source and treated as client input. Reload records and authorization facts inside each handler; do not put model instances or secrets in State.
Await a server result from Component.js
Tetra generates promise-returning methods on its Alpine object:
const value = await this.increment(1);
Citry supplies sendEvent to Component.js and $sendEvent to Alpine expressions:
$component(({ scope, sendEvent }) => {
scope.addOne = async () => {
const result = await sendEvent("increment", { amount: 1 });
return result.count;
};
});
Returning actions.Data(value), or a plain dictionary, resolves that promise. Render actions in the same result still apply in their listed order.
Inside a template, the equivalent is $sendEvent("increment", { amount: 1 }). Prefer @c-click="increment(...)" when the return value is not needed because the declarative binding also owns loading and modifier behavior.
Replace the callback proxy with closed actions
Current Tetra permits _dispatch as its generic callback path; custom self.client.* paths shown in older documentation are blocked by its client whitelist:
@public
def complete(self, task_id):
mark_complete(task_id)
self.client._dispatch(
"TaskEditor:completed",
{"message": "Task complete"},
)
self.update_html()
Citry returns explicit effects. A browser event hands presentation to client code, while a targeted render updates the server-owned region:
class TaskIn:
task_id: int
class TaskSummary(Component):
citry = citry_app
class Kwargs:
task_id: int
status: str
class Slots:
pass
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"task_id": kwargs.task_id, "status": kwargs.status}
template = """
<p id="task-summary">Task {{ task_id }}: {{ status }}</p>
"""
class TaskEditor(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
class Events:
def complete(self, data: TaskIn):
mark_complete(data.task_id)
return [
actions.Dispatch(
"TaskEditor:completed",
{"taskId": data.task_id},
),
actions.Render(
TaskSummary(task_id=data.task_id, status="complete"),
target="#task-summary",
),
]
template = """
<button @c-click="complete({ task_id: 42 })">
Complete task
</button>
<p id="task-summary">Task 42: pending</p>
"""
Listen for TaskEditor:completed with $onEvent or addEventListener. Keep the dispatch before a render that could remove its listener.
The closed action vocabulary prevents Python from traversing arbitrary client objects. It also makes every possible response visible in the handler's return value.
Keep Alpine behavior local to the receiving component
Tetra merges public state, generated server methods, and component JavaScript into one Alpine data object. Citry instead keeps three ownership rules:
Statebelongs to the interactive Citry component instance.- Component.js adds instance-local Alpine variables through its
scopecallback value. - Alpine expressions authored on a child component tag use the parent's scope. Pass a callback through
$c-propswhen the child should use it in the child's own scope.
This distinction matters for slots and multi-root components. Citry's graph keeps the component and slot owner explicit even when their DOM ranges overlap or have no element root.
Translate dynamic HTML attributes
An ordinary Citry HTML attribute is a literal string, so Django-style braces inside it are not evaluated:
<!-- Wrong in a Citry template: braces appear in the final URL. -->
<a href="{{ task_url }}">Open task</a>
Use a c- dynamic attribute for a Python expression:
<!-- Right: task_url is evaluated during rendering. -->
<a c-href="task_url">Open task</a>
Use the same rule for dynamic action, src, class, and other HTML attributes. {{ expression }} remains the text-interpolation spelling.
Syntax mapping
| Tetra | Citry Events |
|---|---|
count = public(0) | class State: count: int = 0 |
| Private pickled component attribute | Reload from the database or derive during the fresh render; use server-held strict JSON State only when it must persist |
@public method | Public method inside class Events |
@public.debounce(200) | @event(debounce=200) |
@public.watch("query") | :c-query="refresh" or an Alpine watcher that calls $sendEvent |
@click="increment()" | @c-click="increment" for a declarative server call |
await this.method(...) | await sendEvent(name, data) or $sendEvent(...) |
| Public method return value | actions.Data(value) or a returned dictionary |
| Automatic re-render | Return a fresh component or actions.Render(...) |
self.client._dispatch(...) | actions.Dispatch(name, detail) |
Other self.client.* callbacks | A closed action, or a dispatched event handled by client code |
| Encrypted pickled component | Signed strict JSON State, or _storage = "server" for server-held strict JSON |
| Alpine component script export | Component.js $component(({ scope, ... }) => {...}) callback |
x-data / x-model | Ordinary Alpine remains available; $state and :c-* bridge Citry State |
Choose explicit behavior for larger Tetra features
Citry v1 focuses on HTTP interactions. Reactive server push and WebSocket topics are a separate later decision. Multipart uploads, batched download actions, and optional State encryption also sit outside v1. A dedicated @event(bundle=False) handler can return actions.Download(...), while history updates use actions.PushUrl(...) or actions.ReplaceUrl(...). The Events migration parity matrix records the delivery tag for each capability.
Citry does not carry Tetra's offline mutation queue or arbitrary server-selected callback paths. Replaying calls across a deployment requires application-specific conflict rules, and a closed action list is easier to audit. Model and form objects are reloaded or validated in Python rather than unpickled from the client token.
Finish the port
Before shipping a migrated component, check that:
- every public Tetra attribute has been classified as
Kwargs, State,js_data, or local Alpine data; - State contains strict JSON values and no secret;
- each public method has an explicit render, data, event, or redirect result;
- server-selected client callbacks have become named Dispatch actions;
- database rows are reloaded and authorized per call; and
- components that require server push, uploads, batched downloads, or history changes have been checked against the parity matrix.
Continue with event bindings and event actions for the complete runtime workflow. See Client interactivity for Component.js, $c-props, slots, and Alpine ownership.