DEV Community

Jean Hoccart
Jean Hoccart

Posted on Edited 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.
  • Change detection has a cost. When an action starts, the server snapshots each state it touches; when it ends, it compares values. That's why reassignment, in-place changes (items.append(...)) and nested mutations are all caught without a proxy, but it also means every touched state is copied and compared on each action. Keep states small and focused.
  • 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 (3)

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

The deps-based invalidation is the cleanest answer to the "what do you send back" question I have seen - deriving the re-render set from changed state instead of hand-wired hx-targets removes a whole class of stale-attribute bugs. The deep-mutation gotcha makes me wonder how you explain the mental model to newcomers: do they think in terms of reassignment only, or is there a change-tracking proxy underneath?

Collapse
 
jean_hoccart profile image
Jean Hoccart •

Thanks, Arjun! No proxy. When an action starts, each state it touches is snapshotted (a deep copy of its field values); when the action ends, the server compares the current values with that snapshot. So reassignment, in-place changes like items.append(...) and nested mutations are all caught, and newcomers can just write normal Python.

I have to correct my own post: the "deep-mutation gotcha" line was wrong, written from an outdated note in the Kanban example. The real trade-off is cost: every state an action touches is copied and compared, so it pays to keep states small and focused. I've updated the article.

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

Thanks for the correction β€” snapshotting state when the action starts and diffing at the end is a neat way to catch in-place mutations while letting people keep writing plain Python.