Server state
Server state is the source of truth: it lives in Python, it is stored server side, it can be shared and protected. One inherits from one of the pre-scoped bases according to the scope wanted.The four scopes
One does not pass `scope=`: one inherits from the base. The lifetime changes, the API is identical.| Base | Scope | Lifetime |
|---|---|---|
| PageState | 1 page shown | survives actions (POST), reset on F5 / nav |
| SessionState | session cookie | until the session expires |
| UserState | authenticated account | AuthRequiredError with no auth |
| AppState | whole process | shared by every request |
Mutate → re-render
A handler mutates the state; every `@refreshable(deps=[…])` zone that reads that state is re-rendered automatically. The detail (controlling what re-renders, realtime) is in “Server reactivity”.0
Declaring a state
An immutable default → directly. A mutable default → `field(default_factory=…)`. `@validator` normalises on write, `@computed` derives automatically.class Cart(SessionState):
"""A rich example — field / factory / validator / computed together."""
items: list[dict] = field(default_factory=list)
coupon: str = field(default='')
discount: float = field(default=0.0)
@validator("coupon")
def _upper(self, value: str) -> str:
return value.strip().upper()
@computed
def total(self) -> float:
raw = sum(it.get("price", 0.0) for it in self.items)
return raw * (1 - self.discount)
Business types in the store
A field is not limited to what JSON can write. `date`, `datetime`, `Decimal`, `UUID` and any app enumeration cross the store and come back the RIGHT type — you never read back a string to re-parse. The containers follow: `list[date]`, `dict[str, Decimal]`.class Facture(SessionState):
"""BUSINESS types filed as they are — not strings to re-parse."""
emise: datetime.date = field(default_factory=datetime.date.today)
montant: decimal.Decimal = field(default_factory=lambda: decimal.Decimal("0"))
reference: uuid.UUID = field(default_factory=uuid.uuid4)
etat: Etat = field(default=Etat.BROUILLON)
For a type of your own, `register_type(MyType, encode=…, decode=…)` once at startup. It is the same route as the shipped types: there is no special case reserved for the framework.What concurrency breaks
Two requests writing the SAME field do not see each other. The commit writes only the fields touched, so two gestures on different fields coexist with no effort — but two gestures on the same total lose each other, in silence: the counter advances more slowly than the clicks, and only a second user reveals it.class Visites(AppState):
"""A total SHARED by the whole process — hence `merge="add"`."""
vues: int = field(default=0, merge="add")
`merge="add"` declares the field ADDITIVE: the store combines the two increments instead of keeping one. With no waiting, no lock. `bretzel check` has a rule for forgetting it (`undeclared-shared-counter`), because the mistake neither raises nor shows.When the gesture COMPUTES from what it read — filtering a list, removing an item — no merge can repair it. There, one serialises:
def delete(target: str) -> None:
with Kanban.lock() as store:
store.tasks = [t for t in store.tasks
if t["id"] != target]
The block is a small transaction: the lock taken THEN the state re-read on entry, the fields written THEN the lock released on exit. Two requests on the same key wait for each other — that is the price, and it is paid only there. `async with` inside an `async def`.State in the URL
A `PageState` is keyed by a render uuid that is NEW at every navigation: that is why a sort or a filter survives neither a page change nor the back button. Declaring the fields the URL is authoritative for makes the view shareable by link, and the browser's arrows go back and forth again.class Ventes(PageState, addressable=True):
"""State in the ADDRESS — two fields published, one that is not."""
region: str = field(default="all", url="region")
mini: int = field(default=0, url="mini")
#: No ``url=``: so this field cannot be published, even with
#: ``addressable=True``. It is the guarantee, not a guideline.
notes: str = field(default="")
`field(url=…)` NAMES, `addressable=True` TURNS ON — and the two are separate because a published field is PUBLIC: browser history, access logs, `Referer` header. A field nothing has named cannot leave by accident; that is how `DatatableState.filters` stays out of the address by construction. To survive a navigation WITHOUT being published, it is the scope you need: `scope="session"`.The states, at a glance
Scope, fields (type, default, validators) and computed of this page's states — read at render time from the code.SrvCounterServerStatesession
| Field | Type | Default | @validator |
|---|---|---|---|
| n | int | 0 | — |
CartServerStatesession
| Field | Type | Default | @validator |
|---|---|---|---|
| items | list | list() (factory) | — |
| coupon | str | '' | _upper |
| discount | float | 0.0 | — |
@computed :total
FactureServerStatesession
| Field | Type | Default | @validator |
|---|---|---|---|
| emise | date | today() (factory) | — |
| montant | Decimal | <lambda>() (factory) | — |
| reference | UUID | uuid4() (factory) | — |
| etat | Etat | <Etat.BROUILLON: 'brouillon'> | — |
VisitesServerStateapp
| Field | Type | Default | @validator |
|---|---|---|---|
| vues | int | 0 | — |
VentesServerStatepage
| Field | Type | Default | @validator |
|---|---|---|---|
| region | str | 'all' | — |
| mini | int | 0 | — |
| notes | str | '' | — |
In the URL:region → regionmini → mini
A field missing from this row never goes into the URL.