Handle and validate forms
Buttons are only one way to call Python. A Citry event can also receive the named values from a form.
You will build an email form, reject the wrong domain in Python, and show the field error beside the input without clearing what the reader typed.
Continue from Keep a value between calls. Keep citry_setup.py and app.py unchanged.
Add the form
Replace components.py with this version. Here we replace the ChoicePicker with a sign-in form:
from citry import Component
from citry.ext.events import EventError, actions
from citry_setup import citry_app
# New in this step: describe the named values sent by the form.
class SignupIn:
email: str
# New in this step: Sign up form with server-side validation
class SignupForm(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
class Events:
# Validate the form and return field errors
def submit(self, data: SignupIn):
email = data.email.strip()
if not email.endswith("@example.com"):
raise EventError(
"Please fix the email address.",
fields={"email": "Use an @example.com address."},
)
return actions.Dispatch(
"signup:sent",
{"email": email},
)
template = """
<section
class="signup-form"
x-data="{ acceptedEmail: '' }"
@signup:sent="acceptedEmail = $event.detail.email"
>
<form @c-submit.prevent="submit">
<label>
Work email
<input
name="email"
type="email"
autocomplete="email"
required
/>
<span
class="signup-form__error"
role="alert"
x-show="$error('submit')?.fieldErrors?.email"
x-text="$error('submit')?.fieldErrors?.email || ''"
></span>
</label>
<button
type="submit"
:disabled="$loading('submit')"
x-text="$loading('submit') ? 'Sending' : 'Send request'"
>
Send request
</button>
</form>
<p role="status" x-show="acceptedEmail">
Accepted <output x-text="acceptedEmail"></output>.
</p>
</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>Join the reading room</title>
</head>
<body>
<main>
<h1>Join the reading room</h1>
{# New in this step: place the form on the page. #}
<c-SignupForm />
</main>
</body>
</html>
"""
Open http://127.0.0.1:8000/ and enter ada@elsewhere.test. The address is valid enough for the browser, so the form reaches Python. Citry then shows βUse an @example.com address.β below the input.
Change the value to ada@example.com and submit again. The page reports that the address was accepted.
Submit form to event handler
The form calls the submit Python event handler instead of performing the browser's usual full-page submission:
<form @c-submit.prevent="submit">
<label>
Work email
<input
name="email"
type="email"
autocomplete="email"
required
/>
</label>
...
</form>
The .prevent modifier stops the usual page navigation. Citry collects the form's named controls and sends them to Python. Here, name="email" gives the typed value its field name.
The browser checks that the input looks like an email address and is not empty. The application-specific @example.com rule still belongs in Python.
Declare form data
On the server, SignupIn names the fields expected by the handler:
class SignupIn:
email: str
class Events:
def submit(self, data: SignupIn):
email = data.email.strip()
The input's name="email" matches SignupIn.email, so the handler can read data.email. A larger form can add more named controls and matching fields to the input class.
The Python type hint SignupIn describes the expected input shape. The Forms guide covers larger input shapes and validation patterns.
Reject input in Python
The handler checks the cleaned address and raises EventError when the domain is wrong:
if not email.endswith("@example.com"):
raise EventError(
"Please fix the email address.",
fields={"email": "Use an @example.com address."},
)
The first string is the overall error message, available as $error('submit')?.message if you want a message for the whole form. The fields mapping adds messages for specific inputs. Its email key matches both SignupIn.email and the input's name="email".
Show handler error in UI
The span beside the input reads that field message from $error('submit'):
<span
class="signup-form__error"
role="alert"
x-show="$error('submit')?.fieldErrors?.email"
x-text="$error('submit')?.fieldErrors?.email || ''"
></span>
Before an error occurs, $error('submit') returns null and the span stays hidden. After the failed call, fieldErrors.email contains "Use an
@example.com address." The form itself remains in place, so the input keeps the address that needs fixing. Naming the handler matters when one component contains several forms: a successful call clears only that handler's error.
Show handler loading in UI
The submit button reads the same handler name through $loading('submit'):
<button
type="submit"
:disabled="$loading('submit')"
x-text="$loading('submit') ? 'Sending' : 'Send request'"
>
Send request
</button>
While submit is running, the button is disabled and its label changes to βSending.β This prevents an accidental second submission and tells the person that the first one is still being handled.
Handle success in the browser
A valid address returns another browser event:
return actions.Dispatch(
"signup:sent",
{"email": email},
)
The form's root listens for that event and keeps the returned address in Alpine:
<section
x-data="{ acceptedEmail: '' }"
@signup:sent="acceptedEmail = $event.detail.email"
>
...
<p role="status" x-show="acceptedEmail">
Accepted <output x-text="acceptedEmail"></output>.
</p>
</section>
The listener is on the SignupForm root on purpose. Dispatch fires the bubbling event from that first root, so the root receives it. Moving @signup:sent inside the <form> would not work because the event does not bubble down into descendants. When root placement is awkward, use $onEvent or the onEvent member from $component to listen by component instance instead. The event actions guide explains the complete targeting rules.
Using $component would have looked like this:
$component(({ onEvent, scope }) => {
// Set initial Alpine state, replaces root x-data
scope.acceptedEmail = '';
// Update Alpine state on server event
onEvent('signup:sent', (detail) => {
scope.acceptedEmail = detail.email;
});
});
The success path updates Alpine data, so it does not need new HTML from Python. The next lesson will keep the same form and change only that success result.
Next steps
An event does not have to stop at an error or browser event. Next, replace part of the page from Python.