{"version":"v1.0","client_state":{"DocsNavigation.default":{"fields":{"query":""},"persist":"memory","send_to_server":true},"ColorScheme.default":{"fields":{"mode":"system"},"persist":"local","send_to_server":true}},"endpoints":{"action":"/_bretzel/action","sse":"/_bretzel/sse","refetch":"/_bretzel/refetch"},"csrf":"ae5867c0270af52495daff3853211712","page_id":"380e851ce9de42fc968fdf17ec0b3e04","address":""}

Forms

Fields, a `ui.form` around them, and the validation on the STATE — not on the view. That last point is what makes the difference between “the form refuses” and “the data cannot be wrong”.

The skeleton

`ui.form` is a container: its children are written inside a `with`. `on_submit=` receives the form's values.
def register(name: str, email: str) -> None:
    Account().create(name=name, email=email)

with ui.form(on_submit=register):
    with ui.form_field(label="Name", required=True):
        ui.input(name="name")
    with ui.form_field(label="Email",
                       hint="A work address, preferably"):
        ui.input(name="email", type="email")
    ui.button("Create", type="submit", color="primary")
`ui.form_field` carries the label above, the field in the middle, the hint or the error underneath. It also sets the accessibility links — a screen reader announces the error with the field, without anyone writing it.

Validation lives on the STATE

A `@validator` attaches to a typed state field. It runs on EVERY assignment — so from the form as much as from an import, a handler, a script.
from bretzel.state import SessionState, field, validator

class Signup(SessionState):
    email: str = field(default="")
    age: int = field(default=0)

    @validator("email")
    def _valid_email(self, value: str) -> str:
        """A FIELD validator: it receives the value and
        returns the one we keep — so it can normalise
        too."""
        value = value.strip().lower()
        if "@" not in value:
            raise ValueError("Invalid address.")
        return value

    @validator
    def _consistent(self) -> None:
        """An INSTANCE validator: it runs after any
        mutation, and sees every field."""
        if self.age < 18 and self.email.endswith(".pro"):
            raise FormError("Business account: 18 minimum.")
FormWhen it runsWhat it receives
@validator("field")on every assignment to THIS field`(self, value)` — and returns the value to keep
@validatorafter any mutation of the instance`(self)` — it sees every field, and raises to refuse

Showing the error

`ui.form_field(error=…)` shows the message under the field AND sets `aria-invalid` — the two go together, otherwise the error exists on screen and not for a screen reader.
signup = Signup()
with ui.form_field(label="Email",
                   error=signup.errors.get("email")):
    ui.input(name="email", value=signup.email)
Two ways of refusing
`FormError` is the raise meant to TRAVEL BACK to the form — that is what distinguishes it from a bare `ValueError`, which says “this value is impossible” and comes back as a server error.

Why not on the view

Because an app has several write routes and one form. A CSV import, a handler called from another page, a data migration: all write into the same state, none goes through the view. A rule placed on the field covers them all; a rule placed on the form covers one.
Typed state, its four scopes, and the fields:Server state →Server actions →