Call Python from a click
Your FastAPI app can render the page. Now you will add a button that reaches a Python handler through Citry's mounted routes. Python will load the choice picker's options without reloading the page.
Start with Serve the page with FastAPI if the app is not already running.
Build the page
Replace components.py with this version:
Look for the New in this step comments. This version:
- adds the database stand-in
load_choices_from_database() - adds the Python handler
ChoicePicker.Events.load_choices() - changes the picker so it starts empty and fills itself from the handler's browser event.
from citry_setup import citry_app
from citry import Component
from citry.ext.events import actions
# New in this step: stand in for a database query.
def load_choices_from_database() -> list[str]:
return ["Ocean", "Forest"]
class ChoiceButton(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
template = """
<button class="choice-button" type="button">
Choose
<span
class="choice-button__label"
x-text="clientProps.label"
></span>
</button>
"""
js = """
$component({
props: {
label: { type: String, required: true },
},
init: ({ props, scope }) => {
scope.clientProps = props;
},
});
"""
class ChoicePicker(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
# New in this step: handle the button click in Python.
class Events:
# Method name matches @c-click.
def load_choices(self):
choices = load_choices_from_database()
# Tell the client to dispatch a custom browser event
# with the loaded choices.
return actions.Dispatch(
"choice-picker:loaded",
{"choices": choices},
)
template = """
<section
class="choice-picker"
x-data="{
choices: [],
choice: '',
setNextChoice() {
const choices = this.choices;
const oldChoiceIndex = choices.indexOf(this.choice);
const nextChoiceIndex =
(oldChoiceIndex + 1) % choices.length;
this.choice = choices[nextChoiceIndex];
},
loadChoices(newChoices) {
this.choices = newChoices;
this.choice = newChoices[0];
},
}"
@choice-picker:loaded="loadChoices($event.detail.choices)"
>
{# New in this step: ask Python for the choices. #}
<button
type="button"
:disabled="$loading('load_choices')"
@c-click="load_choices"
>
Load choices
</button>
<span x-show="$loading('load_choices')">Loading...</span>
<p x-show="choices.length === 0">
No choices loaded yet.
</p>
<div x-show="choices.length > 0">
<p>
Current choice:
<output
class="choice-picker__value"
x-text="choice"
></output>
</p>
{# New in this step: cycle through the loaded choices. #}
<c-ChoiceButton
$c-props="{ label: choice }"
@click="setNextChoice"
/>
</div>
</section>
"""
class TutorialPage(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
template = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Reading room</title>
</head>
<body>
<main>
<h1>Reading room</h1>
<c-ChoicePicker />
</main>
</body>
</html>
"""
Keep citry_setup.py and app.py unchanged. Uvicorn should reload the app after you save the file.
Open http://127.0.0.1:8000/ and click βLoad choices.β The empty picker fills with the two choices. Its existing child button can then move between them in the browser.
Send the click to Python
This is the line that turns an ordinary button into a server-handled action:
<button type="button" @c-click="load_choices">
Load choices
</button>
The Alpine @click from the previous lesson runs JavaScript in the browser. Citry uses the @c-* prefix for events that are meant to be handled by the server.
When you click on the button with @c-click:
- Alpine detects the
clickevent and hands it over to Citry JS client. - Citry JS client sends request to the server (the FastAPI app). The payload includes which component (
ChoicePicker) and which event was triggered (load_choices). - On the server, the routes that were installed when we mounted Citry onto FastAPI will pick up this request (
/citry/...). - Citry's routing passes the request and event data to
load_choices()event handler on theChoicePickercomponent.
Because the page and Citry routes share the application, this request stays on the same origin.
Warning
DO NOT blindly trust data in event handlers - anyone can send events. Your application still needs to authenticate people and check their permissions inside handlers. See Security for the complete trust boundaries.
Server event handlers
The handler lives in the component's nested Events class:
class Events:
def load_choices(self):
choices = load_choices_from_database()
return actions.Dispatch(
"choice-picker:loaded",
{"choices": choices},
)
Public methods in Events are handlers the browser can call. Here, load_choices matches the name on the button.
The small function above the components stands in for your application's data access:
def load_choices_from_database() -> list[str]:
return ["Ocean", "Forest"]
The page starts without those choices. This function runs only after the button calls the handler. In a real application, this is where you might query a database or call another Python service.
Handler response
The handler returns an actions.Dispatch(...) action:
return actions.Dispatch(
"choice-picker:loaded",
{"choices": choices},
)
This tells the browser to fire an event named choice-picker:loaded. The second argument becomes that event's detail, so the returned list is available as $event.detail.choices. Values in detail must be JSON-serializable because they travel from Python to browser code.
Note
@c-click is a convenience over sendEvent(): as such, it starts the handler but does not expose the handler's Promise result.
That is why this example returns Dispatch (to trigger a browser event) rather than actions.Data which would have returned data from a Promise.
Component JavaScript can use sendEvent() when it owns the call and needs Data as a one-off return value.
The picker defines a small Alpine method for storing the result:
loadChoices(newChoices) {
this.choices = newChoices;
this.choice = newChoices[0];
}
Its listener calls that method when the matching event arrives:
@choice-picker:loaded="loadChoices($event.detail.choices)"
The existing $c-props connection then passes that selected label to ChoiceButton. No HTML needs to be replaced.
Note
When the server returns Dispatch action, the browser fires a bubbling DOM event from that component's first live root.
Here, the first root is the same element that carries @choice-picker:loaded, so the listener receives the event.
Placement matters for an ordinary @event listener. The first root or one of its ancestors can hear the bubbling event. A descendant CANNOT, because DOM events do not bubble downward. If a component has several roots, a listener on another root will not hear it either.
For a multi-root component, prefer $onEvent in an Alpine expression or the onEvent function in $component. Both listen for server events targeting the current component instance. Their callbacks receive detail directly, without $event.detail. See event actions for the complete delivery rules.
Loading state
The full button also uses the Citry-specific Alpine magic $loading('load_choices'):
<button
type="button"
:disabled="$loading('load_choices')"
@c-click="load_choices"
>
Load choices
</button>
<span x-show="$loading('load_choices')">
Loading...
</span>
$loading('load_choices') is true while that handler call is in progress. During a slower real query, the button becomes disabled and the loading message appears. Both return to normal when the call finishes.
Process response
After Python supplies the list, another Alpine method finds the next choice:
setNextChoice() {
const choices = this.choices;
const oldChoiceIndex = choices.indexOf(this.choice);
const nextChoiceIndex =
(oldChoiceIndex + 1) % choices.length;
this.choice = choices[nextChoiceIndex];
}
The child button calls that method with an ordinary browser click:
<c-ChoiceButton
$c-props="{ label: choice }"
@click="setNextChoice"
/>
This uses Alpine's @click, so moving from βOceanβ to βForestβ does not make another server call. The page now uses Python when it needs data and browser code when the interaction can stay local.
Each server call starts fresh
This first handler is stateless. Each click calls load_choices_from_database() again and returns the same set. Python does not remember that the page loaded it before. Alpine can still move between those choices in the browser.
Here, stateless means the component has no Citry State carried from one call to the next. A real handler can still read persistent application data from a database, session, or another service.
That makes this pattern a good fit for refreshing a result or asking the server for the latest version of some data. The next lesson adds State so Python can continue from an earlier call.
Continue with event bindings for loading and errors, or event actions for the other ways a handler can update the page.
Next steps
The browser can now reach Python and receive a result. Next, keep a value between calls.