Theme
Version
GitHub PyPI Discord
On this page

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.