Migrate from livecomponents
If a livecomponents page depends on Redis-held state and path-shaped component ids, moving state and hierarchy at the same time creates unnecessary risk. Citry supports a two-step port: first keep State on the server and reproduce the current behavior, then choose signed browser-held State separately for each component.
Both steps use strict JSON schemas and named handlers. Neither requires pickling component instances, saving template source, or manually threading a parent id through every child.
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
Step one: keep State on the server
A livecomponents counter declares a Pydantic state and mutates it through a CallContext. The framework marks the component dirty and re-renders it:
class CounterState(LiveComponentsModel):
count: int = 0
class Counter(LiveComponent[CounterState]):
def init_state(self, context: InitStateContext) -> CounterState:
return CounterState()
@command
def increment(self, call_context: CallContext[CounterState]):
call_context.state.count += 1
Port the state fields directly, set _storage = "server", and narrow _public to the fields that may appear in the browser. The handler now returns the fresh render explicitly:
class ServerCounter(Component):
citry = citry_app
class Kwargs:
count: int = 0
class Slots:
pass
class State(Kwargs):
_storage = "server"
_public = ("count",)
class Events:
def increment(self, state):
state.count += 1
return ServerCounter(count=state.count)
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"count": kwargs.count}
template = """
<button @c-click="increment">
Count: {{ count }}
</button>
"""
The browser token contains an opaque reference while the strict JSON State lives in Citry.cache. Public fields are still emitted separately for templates and bindings, so _storage = "server" is not a visibility control. Omit secrets and server-only facts from _public; the example exposes only count. The default cache is in-process memory, which is useful for development and one-process deployments. Configure a shared cache backend before running several workers so every request can resolve the same State.
This first step changes the library boundary without forcing a storage-policy change. Keep large values in the server store and server-only facts outside _public while the component behavior settles.
Step two: opt suitable components into signed State
When a component's State is small, non-secret strict JSON, remove the server storage setting by using the default State declaration:
class SignedCounter(Component):
citry = citry_app
class Kwargs:
count: int = 0
class Slots:
pass
class State(Kwargs):
pass
class Events:
def increment(self, state):
state.count += 1
return SignedCounter(count=state.count)
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"count": kwargs.count}
template = """
<button @c-click="increment">
Count: {{ count }}
</button>
"""
The handler and template stay the same. State now travels in a full-HMAC signed token, so the server store is not needed for that component.
Signed means tamper-evident, not encrypted. The values are visible in page source and must be treated as client input. Components containing secrets or large data can remain on _storage = "server" indefinitely, with _public narrowed so secrets are not emitted separately. The migration is per component, not an all-or-nothing application switch.
Return every affected region explicitly
livecomponents commands accumulate execution results. A command can mark itself, its parent, or another component dirty, and the response carries several out-of-band fragments:
@command
def save(self, call_context: CallContext[TaskState], task_id: int):
save_task(task_id)
return [
ComponentDirty("task-summary"),
TriggerEvents([Event(name="task-saved", detail={"taskId": task_id})]),
]
Citry returns the same intent as ordered actions. A selector names the region to render, and a browser event handles client-owned follow-up behavior:
class TaskIn:
task_id: int
class TaskSummary(Component):
citry = citry_app
class Kwargs:
task_id: int
class Slots:
pass
def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, Any]:
return {"task_id": kwargs.task_id}
template = """
<p id="task-summary">Task {{ task_id }} saved</p>
"""
class TaskEditor(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
class Events:
def save(self, data: TaskIn):
save_task(data.task_id)
return [
actions.Render(
TaskSummary(task_id=data.task_id),
target="#task-summary",
),
actions.Dispatch(
"TaskEditor:saved",
{"taskId": data.task_id},
),
]
template = """
<button @c-click="save({ task_id: 42 })">
Save task
</button>
<p id="task-summary">Task 42 not saved</p>
"""
Use actions.Render for each independent component instance that must evolve separately. If one selector matches several elements, Citry creates mirrored placements of one logical instance: they share State and later self-renders update all placements together.
Automatic parent and find_one() command routing is intentionally absent. Use an explicit target when Python owns the update, or Dispatch an application event when the receiving component owns the reaction. This keeps the relationship visible at the call site and avoids path-encoded parent ids.
Build every later render from explicit inputs
livecomponents can save component state, selected outer context, and raw template source so it can reconstruct a later render. Citry handlers receive no original kwargs or fills. A handler builds a fresh component tree from its State, request context, and newly loaded records:
class Events:
def refresh(self, state, request):
task = load_task_for_user(
task_id=state.task_id,
user=current_user(request),
)
return TaskEditor(task=task)
If the new tree needs slot content, supply the fills again in that returned tree. This rule prevents stale request context or saved template text from silently reappearing on a later call.
Translate dynamic HTML attributes
Citry leaves ordinary HTML attribute values as literal strings. Django-style interpolation inside an attribute therefore produces the wrong URL:
<!-- Wrong in a Citry template: braces are sent to the browser. -->
<a href="{{ command_url }}">Run command</a>
Use c- for a dynamic HTML attribute:
<!-- Right: command_url is evaluated during rendering. -->
<a c-href="command_url">Run command</a>
Apply the same rule to dynamic action, src, class, and other attributes. Text interpolation continues to use {{ expression }}.
Syntax mapping
| livecomponents | Citry Events |
|---|---|
LiveComponentsModel | Dataclass-shaped class State |
| Redis/pickle State | First step: _storage = "server" with strict JSON in Citry.cache |
| Browser-independent migration step | Keep server storage for as long as the component needs it |
| Optional move away from Redis | Second step: default signed State, component by component |
@command | Public method inside class Events |
CallContext.state | state injectable |
CallContext.request | framework-neutral request injectable |
| Body kwargs splatted into command | Typed data schema with coercion and 422 field errors |
Implicit ComponentDirty | Return a fresh component or actions.Render(...) |
ParentDirty / ComponentDirty(id) | Explicit actions.Render(target=...) |
TriggerEvents | actions.Dispatch(name, detail) |
RedirectPage | actions.Redirect(url) |
PushUrl / ReplaceUrl | actions.PushUrl(url) / actions.ReplaceUrl(url) |
parent_id and component path | No author-managed hierarchy id; use target or Dispatch |
{% call_command ... %} / hx-post | @c-*, $sendEvent, or a per-event URL |
| htmx + json-enc + Alpine morph setup | Citry loads and coordinates its client runtimes automatically |
| Saved context and template source | Fresh tree with explicit kwargs and fills |
Keep serialization and authorization explicit
Citry uses strict JSON for both storage modes. It does not pickle State, component instances, Django forms, models, or template objects. Reload models by validated id and treat a missing or unauthorized row as an ordinary event error.
The session id is not authorization. Put authentication and authorization in the host middleware, an Events guard, or the handler itself. Repeat the check for every event even when the page that rendered the component was protected.
Staged uploads, batched download actions, and server push sit outside Events v1. A dedicated @event(bundle=False) handler can return actions.Download(...); history updates use actions.PushUrl(...) or actions.ReplaceUrl(...). The Events migration parity matrix records their delivery tags and the accepted replacement for deliberately excluded behavior.
Finish each migration step
For the server-held first step, check that:
- the State schema contains every value the next call truly needs;
- a shared cache is configured for multi-worker deployment;
- every handler returns the affected render, event, redirect, or data; and
- old parent-id and saved-context dependencies have explicit replacements.
Before switching one component to signed State, also check that:
- its serialized State is small strict JSON;
- none of its values are secret;
- every client-writable value is revalidated; and
_publicand_modelexpose only the intended fields.
Continue with State and event actions for fresh renders, and Security for cache, CSRF, and client-input guidance.