DEV Community

Jean Hoccart
Jean Hoccart

Posted on

How a server-driven Python framework decides which part of the page to re-render

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-target and hx-swap for 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)
Enter fullscreen mode Exit fullscreen mode

Three things to notice:

  1. Counter is typed state with an explicit lifetime: SessionState lives 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.
  2. increment is a plain function. It doesn't return HTML and doesn't touch the DOM: it mutates state.
  3. count is a region. deps=[Counter] says "re-render me when Counter changes".

What happens when you click

  1. The button sends an action request (HTMX carries it; the action is signed, so a client can't call arbitrary handlers).
  2. The server runs increment.
  3. 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.
  4. 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:

  • deps only: instant for me, nobody else cares (a filter, a draft).
  • broadcast only: 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
Enter fullscreen mode Exit fullscreen mode

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)