When the server renders your UI, every click raises the same question: what do you send back?
The usual answers each have a cost:
- Re-run everything. Streamlit's model: the script runs top to bottom on each interaction. Simple to start with, but state lifetimes get blurry as the app grows.
-
Wire it by hand. With HTMX you write
hx-targetandhx-swapfor each interaction. Precise, but the knowledge of "this button changes that table" lives in attributes scattered through your templates. - Move the state to the browser. A React app keeps its own state and its own build pipeline, and the server becomes an API.
I'm building Bretzel, a full-stack Python framework, around a fourth answer: regions of the page declare the state they read, and the framework works out the rest. This post explains how that works, and where it costs you.
A region declares what it reads
Here is a complete page with a counter (it runs on the current release, 0.1.0a2):
from bretzel import page, refreshable, ui
from bretzel.state import SessionState, field
class Counter(SessionState):
value: int = field(default=0)
def increment() -> None:
Counter().value += 1
@refreshable(deps=[Counter], broadcast=[Counter])
def count() -> None:
ui.text(f"Clicked {Counter().value} times")
@page("/")
def home() -> None:
count()
ui.button("+1", on_click=increment)
Three things to notice:
-
Counteris typed state with an explicit lifetime:SessionStatelives as long as the visitor's session. Bretzel also has page, user and app scopes, plus client-only state for things the server never needs to know. -
incrementis a plain function. It doesn't return HTML and doesn't touch the DOM: it mutates state. -
countis a region.deps=[Counter]says "re-render me whenCounterchanges".
What happens when you click
- The button sends an action request (HTMX carries it; the action is signed, so a client can't call arbitrary handlers).
- The server runs
increment. - At the end of the action, the server compares the state before and after. Every region whose
deps=include a state that changed is re-rendered, and nothing else. - The response carries only those HTML fragments. The browser merges them into the existing page with idiomorph instead of replacing whole blocks.
If a handler changes nothing a region depends on, the round trip brings back nothing to swap. You never write "after this action, refresh that table": the dependency is declared once, on the region, next to the code that reads the state.
Other windows follow: broadcast=
deps= answers "what makes my view re-render". broadcast= answers "what should other open windows follow".
When an action changes a state listed in a region's broadcast=, the server sends a small signal over Server-Sent Events. Each other window then re-fetches that region in its own context. No data travels from one client to another, only the signal "this changed".
The two lists are orthogonal, and putting a state in both isn't redundant:
-
depsonly: instant for me, nobody else cares (a filter, a draft). -
broadcastonly: I never change it, but others do (a queue fed by workers). - both: instant for me, pushed to the others (a shared board).
The live Kanban demo shows this best: open it in two windows of the same browser and drag a card in one. The other follows, activity line included, and undo works from either. The server arbitrates every drop: a full column refuses a card simply by not changing the state. The whole app is 9 Python modules and no JavaScript of its own.
What it costs
This model isn't free, and I'd rather say so up front:
- Every interaction is a round trip. On a slow network you feel it. That's why client-only state exists, for things like an open menu or a toggled panel.
- State lives on the server. It's kept in memory by default, with a Redis backend, and each scope has its own lifetime. That's memory you have to think about, which an SPA pushes to the browser.
-
One gotcha: change detection compares states. Reassigning a value (
counter.value += 1,board.cards = [...]) is always seen. Mutating deep inside nested structures is where you have to be careful. - It's an alpha (0.1.0a2) with one maintainer. APIs will move, and I haven't proven anything about large-scale deployments yet.
How it compares
NiceGUI keeps a tree of UI objects on the server for each client. Reflex compiles your app to a Next.js frontend. FastHTML stays deliberately close to HTML and HTMX. I wrote a comparison with sources, including the cases where each of them is the better pick.
Try it
pip install bretzel
bretzel new hello
cd hello && pip install -e .
bretzel dev
- Docs: https://docs.bretzel-py.dev
- Components: https://ui.bretzel-py.dev
- Code (MIT): https://github.com/JeanHoccart/bretzel
The feedback that would help me most: build one small internal tool with it, and tell me exactly where the model, the API or the docs stopped making sense.
Top comments (0)