Theme
Version
GitHub PyPI Discord
On this page

Event actions

An event handler can replace its own component, render somewhere else, return data, dispatch a browser event, change browser history, or navigate. Return one result for one effect, or return a list when the effects must happen in order.

Update another part of the page

actions.Render places a rendered component into a CSS target. This handler updates the cart badge, then dispatches a browser event:

from citry.ext.events import actions


class CartIn:
    product_id: int


class AddToCart(Component):
    citry = citry_app

    class Events:
        def add(self, data: CartIn):
            cart = add_item(data.product_id)
            return [
                actions.Render(
                    CartBadge(count=cart.count),
                    target="#cart-badge",
                    swap="inner",
                ),
                actions.Dispatch(
                    "AddToCart:updated",
                    {"count": cart.count},
                ),
            ]

    template = """
      <button @c-click="add({ product_id: 42 })">
        Add to cart
      </button>
    """

Event names are exact strings. Prefix application events with the component name, such as MyCard:submit or AddToCart:updated. Names beginning with citry: belong to the runtime.

String-based listeners such as $onEvent preserve that exact case. HTML attribute names are lowercased, so use lowercase event names when listening with an Alpine @name attribute.

Choose where to listen for Dispatch

actions.Dispatch sends its browser event back to the component instance whose handler returned it. It does not fire from the button or form that started the call.

The delivery path is:

  1. The server puts the calling component's render ID on the action.
  2. The browser finds that instance's current first root.
  3. It dispatches a bubbling DOM CustomEvent from that root.

For a single-root component, this makes a listener on the root straightforward:

<section @signup:sent="email = $event.detail.email">
  ...
</section>

The event moves upward through the DOM. It never moves down into the root's children. A component with several roots still dispatches only once, from its first live root, so the same logical event does not reach page-wide listeners several times.

ListenerWhat receives the event
@name on the first rootThe root receives it directly.
@name on a DOM ancestorThe event bubbles to the ancestor.
@name inside the first rootNothing; events do not bubble downward.
@name on another root of the same componentNothing; only the first root dispatches.
$onEvent(name, fn)Only events targeting this component instance.
$component's onEvent(name, fn)The same instance-scoped events, with component cleanup.
Citry.events.on(name, fn)Matching events from every instance, plus instance-less events.

A plain DOM listener on an ancestor also hears same-named events from nested components. The instance-scoped $onEvent and onEvent helpers normally filter those out, so they are the safer default for reusable and multi-root components. If two nested components share the very same root element, that element belongs to both instances and both subscriptions receive the event. When Citry connects a newly rendered component to the same logical instance, these subscriptions keep following it even though its render ID changed. Their callbacks receive the event's detail value directly, rather than the whole DOM event.

$onEvent returns a function that removes its subscription. The onEvent member provided to $component ties the subscription to that component initializer's cleanup automatically.

Both instance-scoped helpers listen through document. A DOM listener that stops propagation before the event reaches document also prevents those helpers, and Citry.events.on, from receiving it.

If the calling instance has been removed or has no live root by the time the action runs, Citry drops the event instead of silently dispatching it from document. Calls with no component instance are the exception: their Dispatch events start at document. [Citry.events.on][citry-events-on] receives them, but component-local DOM listeners, $onEvent, and $component's onEvent do not.

Return actions in the order they must happen

A handler can return one result. It can combine ordinary actions in a list, which Citry applies in order. A download is the exception: it must be the only result of an unbundled handler.

Return valueBrowser result
MyComponent(...)Render and morph over the calling instance.
actions.Render(...)Render into an explicit target with the selected swap.
dict or actions.Data(value)Resolve an imperative caller's Promise with JSON data.
actions.Dispatch(name, detail)Dispatch a bubbling browser CustomEvent.
actions.Redirect(url)Navigate the page.
actions.PushUrl(url)Add a browser history entry without navigating.
actions.ReplaceUrl(url)Replace the current browser history URL without navigating.
actions.Download(...)Download a file by itself. See dedicated download responses.
NoneAcknowledge the call without a visible action.

Choose Data or Dispatch for browser code

actions.Data is a one-call return value. Browser code receives it only when it owns the Promise from an imperative call:

const result = await sendEvent("preview");

A declarative binding such as @c-click="preview" starts the same handler, but the template does not receive that Promise or its Data value. Return actions.Dispatch when browser code must react to the result of a declarative call. A handler may return both when it supports both callers:

return [
    actions.Data({"preview_id": preview.id}),
    actions.Dispatch(
        "Preview:ready",
        {"previewId": preview.id},
    ),
]

Data must settle the caller before later actions continue, so actions.Data(value, wait=False) is rejected. A Data wire action does not carry wait; receiving that field with either value is invalid. delay remains available when the Promise should resolve later.

Order matters when one action removes the audience of another. This version may remove the listener before it receives the event:

return [
    actions.Render(ClosedDialog(), target="#dialog"),
    # Too late if the render removed the listener.
    actions.Dispatch("Editor:closed"),
]

Dispatch first when the old subtree must hear it:

return [
    actions.Dispatch("Editor:closed"),
    actions.Render(ClosedDialog(), target="#dialog"),
]

History actions only update the address and history stack. They preserve the page's existing history.state, do not fire popstate, and do not restore component HTML or State when the user later chooses Back or Forward. Use a client router when URL history must restore page content.

Understand shared and independent render targets

If one Render action's selector matches several elements, Citry inserts one logical component instance in all of them. The placements share one State and one token. A later self-render updates every placement together.

This is ideal for one cart count shown in both desktop and mobile navigation:

<span class="cart-badge-slot"></span>
<!-- ... -->
<span class="cart-badge-slot"></span>
return actions.Render(
    CartBadge(count=cart.count),
    target=".cart-badge-slot",
    swap="inner",
)

The natural first mistake is expecting one of those badges to gain independent State. It cannot: both placements are views of the same instance.

When each region must evolve independently, return one render action per target. Each action renders a distinct component instance:

return [
    actions.Render(
        RegionStatus(region="desktop"),
        target="#desktop-status",
        swap="inner",
    ),
    actions.Render(
        RegionStatus(region="mobile"),
        target="#mobile-status",
        swap="inner",
    ),
]

Preserve identity when lists or parents re-render

Without a key, morphing matches siblings by position. If a list reorders, a focused input, caret, or client-owned widget can remain at the old position instead of following its item. Put #c-key on reorderable <c-for> items:

<c-for each="item in items">
  <article #c-key="item.id">
    <input c-value="item.title">
  </article>
</c-for>

An interactive child under a parent that can re-render also needs a component key, so its client State follows the same domain object:

<c-for each="item in items">
  <c-todo-row
    #c-key="item.id"
    c-item="item"
  />
</c-for>

Element and component keys operate at different levels. An element key only matches within one sibling window. The same element key cannot move a node between parents or nesting depths. This conditional changes depth, so the input is recreated:

<c-if cond="editing">
  <input #c-key="'draft'" />
</c-if>
<c-else>
  <div class="highlight">
    <input #c-key="'draft'" />
  </div>
</c-else>

Keep the keyed node at the same tree position in every branch:

<div c-class="'highlight' if not editing else ''">
  <input #c-key="'draft'" />
</div>

Component tags are virtual nodes bounded by Citry's ownership comments. Citry matches their direct logical children top-down. It reserves keyed children by (component class, key), then pairs the remaining unkeyed positions. An unkeyed pair keeps identity only when both positions hold the same component class; Citry does not scan ahead for another same-class child. A keyed child can therefore move across ordinary wrappers or change between single-root, multi-root, text-only, and empty output while its component State remains attached to that child. Use a key for insertion, deletion, or reorder. An unmatched component is opaque: an equal key deeper inside it cannot leak out and match elsewhere.

Element keys must be unique among the siblings that can compete. Component keys must be unique among direct children of the same logical parent and component class. If duplicate component keys occur, Citry warns and matches them in invocation order.

#c-key needs a non-empty Python expression. You may put it directly on an HTML element or component tag. An element key becomes data-citry-key on that element. A component key stays on the component's virtual ownership range and is never copied onto its rendered roots, so a child's own root may carry an independent element #c-key. When the expression evaluates to None, Citry records no key, exactly as if the flag were absent. This lets a component expose an optional key as an ordinary input. False, 0, and "" remain keys.

With swap="morph", a matched component range keeps both logical State and the physical DOM wherever the morph can preserve it. With swap="replace", the logical component and State still match by key, but all physical nodes and range comments are replaced.

The flag cannot arrive through c-bind, and structural built-in tags such as <c-if> reject it. Transparent component tags such as <c-provide> may carry it: the key identifies their virtual comment-bounded range just like any other component range. Equivalent caller-supplied slot regions inside a matched component also morph their contents, so ordinary element keys keep working there; an added, removed, or otherwise uncorrelated slot region is replaced atomically. Put the key on the HTML or component node whose identity should survive. For why a key belongs on the tag, read Template flags.

Leave a browser-owned subtree alone

Some browser libraries take complete ownership of an element's descendants. Put the bare #c-ignore marker on that element to keep a morph update from changing it or anything inside it:

<div class="chart" #c-ignore>
  <canvas></canvas>
</div>

When an event response morphs an ancestor, Citry leaves this <div> and its subtree as they are in the browser. Use this for a widget that has its own rendering lifecycle, not for content that Citry should keep up to date.

#c-ignore takes no value and cannot be passed through c-bind. It may belong to either an ordinary element subtree or a logical component range.

Write it on a component tag when the complete component should remain exactly as it is in the browser:

<c-BrowserOwnedChart #c-ignore />

Citry retains the old comment-delimited range rather than copying the flag to a rendered root. This works for single-root, multi-root, text-only, and empty components and keeps their old DOM, State, props, callbacks, bindings, fills, and dependencies together. Surrounding ranges and elements may still update.

A flag written on an HTML root inside the component's own template remains an ordinary element flag. It keeps only that root subtree; sibling roots from the same component can still update:

<div #c-ignore>
  <canvas></canvas>
</div>
<output>{{ status }}</output>

Citry uses the old rendered side as the policy source. Adding #c-ignore in a new response takes effect on the following morph. Removing it from an already ignored range is stickyβ€”the old range is still retainedβ€”until the range is removed, explicitly replaced, or stops corresponding because its class or key changed. swap="replace" is explicit replacement and bypasses ignore.

If the caller only wants one physical wrapper kept, ordinary element ignore is still appropriate:

<div #c-ignore>
  <c-BrowserOwnedChart />
</div>

For manually fetched HTML rather than an event result, see HTML fragments.