3. State, events and forms¶
Visitors should be able to sign the guestbook and like messages:
"""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.