3. State, events and forms

Visitors should be able to sign the guestbook and like messages:

guestbook.py
"""Tutorial step 3: state, events and a validated form."""

from dataclasses import dataclass, field
from itertools import count

from pydantic import BaseModel, Field

from rational import (
    App,
    Button,
    Card,
    Column,
    Component,
    Container,
    Form,
    Heading,
    Input,
    Page,
    Row,
    State,
    Text,
    Textarea,
)

app = App(title="Guestbook")


_ids = count(1)


@dataclass(frozen=True)
class Entry:
    name: str
    message: str
    id: int = field(default_factory=lambda: next(_ids))


GUESTBOOK = [  # shared by every visitor, until the server restarts (step 5 adds a database)
    Entry("Ada", "What a lovely place!"),
    Entry("Grace", "Signed, sealed, compiled."),
]


class SignForm(BaseModel):
    name: str = Field(min_length=1, max_length=40)
    message: str = Field(min_length=1, max_length=280)


class EntryCard(Component):
    entry: Entry
    hearts = State(0)  # each card has its own count

    def like(self):
        self.hearts += 1  # assigning state re-renders this card, and only this card

    def render(self):
        return Card(
            Text(self.entry.message),
            Row(
                Text(f"— {self.entry.name}", muted=True, size="sm"),
                Button(f"♥ {self.hearts}", variant="ghost", size="sm", on_click=self.like),
                justify="between",
            ),
        )


@app.page("/")
class Home(Page):
    title = "Guestbook"
    entries = State(factory=list)

    def mount(self):
        self.entries = list(GUESTBOOK)

    def sign(self, form: SignForm):  # the annotation validates the submitted form
        GUESTBOOK.insert(0, Entry(form.name, form.message))
        self.entries = list(GUESTBOOK)

    def render(self):
        return Container(
            Heading("Guestbook", level=1),
            Form(
                Input("Your name", name="name", required=True),
                Textarea("Message", name="message", required=True, rows=3),
                Button("Sign the guestbook", type="submit"),
                on_submit=self.sign,
                key=f"form-{len(self.entries)}",  # a new key after signing: a fresh, empty form
            ),
            Column(*[EntryCard(entry=e, key=e.id) for e in self.entries]),
            max_width=640,
        )


if __name__ == "__main__":
    app.run()

State

hearts = State(0) declares reactive state. Every EntryCard instance has its own count. When like() assigns a new value, Rational re-renders the components that read that state (here, just that card) and patches the page in place.

entries = State(factory=list) gives each page view its own list. Use factory= for mutable defaults, so instances never share a list.

Events

on_click=self.like passes a method. It runs on the server: the browser only knows an opaque handler id, never your method’s name. Rational adds a ~4 KiB script to pages that have handlers and wires everything up for you.

Forms and validation

on_submit=self.sign receives the form’s fields. Because the parameter is annotated with the Pydantic model SignForm, the data is validated before your handler runs. Try submitting an empty form: the errors appear under the right fields, and sign() is never called with bad data.

key=f"form-{len(self.entries)}" gives the form a new identity after each signature. A form with a new key is a fresh form, so the fields are cleared.

Why the entries have ids

New entries are inserted at the top, so positions shift. With key=e.id, identity follows the entry, not its position, and the card you liked keeps its hearts when others move.

Next: 4. Styling and responsive layout.