GitHub

State and reactivity

You never declare reactive state. The compiler reads each page (and component) function and classifies every local variable by how it is used.

#The three kinds of state

KindRuleIn the browser
signalchanged by an event handler (assigned, +=, mutated with .append(), item assignment, del x[i], …) or bound with bind={x}a reactive cell; writing it updates exactly the DOM that reads it
computedassigned once from an expression that reads signals, never changed by a handlera cached derivation, recomputed only after one of its inputs changes
constantanything elsea plain value
app.pyweb
from pyweb import App

app = App()


@app.page("/")
def Cart():
    price = 25            # constant
    quantity = 1          # signal: bound to the input
    total = price * quantity          # computed: reads a signal
    label = "Total"       # constant

    <label>Quantity <input type="number" bind={quantity} /></label>
    <p>{label}: {total}</p>
What this compiles to
JavaScript
// Cart.js
function Cart($s) {
  const price = 25;
  const quantity = $signal("quantity" in $s ? $s["quantity"] : 1);
  const total = $computed(() => $py.mul(price, quantity()));
  const label = "Total";
  return [
    $h("label", null, () => [
      $t("Quantity "),
      $h("input", {"type": "number", "$bind": quantity})
    ]),
    $h("p", null, () => [
      $t($py.text(label)),
      $t(": "),
      $dyn(() => total())
    ])
  ];
}
$mount("Cart", Cart);

Typing in the input updates quantity, which invalidates total, which updates one text node. Nothing else re-renders. There is no virtual DOM.

#Where initial values come from

Separately, each value's origin decides where it is first computed:

OriginExampleBehaviour
literalcount = 0known at compile time
browsertotal = price * quantitycomputed by the generated JavaScript
serverrows = load_rows(), user = session.user(), anything after page-level Python logic (if, loops), anything derived from another server valuecomputed by Python on every request

Server values are rendered into the HTML. Only the ones that browser code (markup or handlers) actually reads are serialised to JSON for the page, so intermediate values such as a full user record stay private. The serialised state must be JSON-compatible; dict, list, str, numbers, bool, None, dataclasses, datetime/date (ISO strings), Decimal (float) and objects with to_dict()/model_dump() are converted automatically.

#Updating state in handlers

Inside a handler, page variables are shared with the page: assigning one updates the page state. (In plain Python this would create a local; in a PyWeb page it is the point.) Names that are not page variables are ordinary locals.

app.pyweb
from pyweb import App

app = App()


@app.page("/")
def Board():
    cards = [{"title": "a", "votes": 0}]
    selected = None

    def vote(i):
        cards[i]["votes"] += 1          # item update: copy-on-write
        selected = i                    # plain assignment

    def sort_cards():
        cards.sort(key=lambda c: -c["votes"])

    <ul onclick={sort_cards}>
        for i, card in enumerate(cards):
            <li onclick={vote(i)}>{card["title"]}: {card["votes"]}</li>
    </ul>
What this compiles to
JavaScript
// Board.js
function Board($s) {
  const cards = $signal("cards" in $s ? $s["cards"] : [{"title": "a", "votes": 0}]);
  const selected = $signal("selected" in $s ? $s["selected"] : null);
  function vote(i) {
    { const $k1 = [i, "votes"]; $py.setp(cards, $k1, $py.add($py.getp(cards.peek(), $k1), 1)); }
    selected(i);
  }
  function sort_cards() {
    $py.mut(cards, [], ($v) => $py.m($v, "sort", $py.kw({"key": ((c) => ((-$py.at(c, "votes"))))})));
  }
  return [$h("ul", {"onclick": sort_cards}, () => [$list(() => $py.enumerate(cards()), ([i, card]) => [$h("li", {"onclick": (() => vote(i))}, () => [
          $t($py.text($py.at(card, "title"))),
          $t(": "),
          $t($py.text($py.at(card, "votes")))
        ])])])];
}
$mount("Board", Board);

Mutations of state (append, extend, insert, pop, remove, clear, sort, reverse, update, setdefault, add, discard, x[i] = v, x[k][j] = v, x.attr = v, del x[i]) are compiled to copy-on-write updates: the changed container and every container on the path to it are copied, siblings are shared. List rows keyed on the changed item re-render; the rest keep their DOM.

Assigning to a computed value or constant from a handler is a compile error, because the value would immediately be recomputed or ignored.

#Reacting to changes

Markup updates by itself. For anything else that should follow a value (redrawing a chart, saving a draft, scrolling a log), call watch(lambda: value, handler) from on_mount. handler(new_value) runs each time the value changes, not for the current value:

app.pyweb
from pyweb import App
from pyweb.browser import watch

app = App()


@app.page("/")
def Notes():
    draft = ""

    def on_mount():
        draft = localStorage.getItem("draft") or ""
        watch(lambda: draft, save)

    def save(text):
        localStorage.setItem("draft", text)

    <textarea bind={draft}></textarea>
What this compiles to
JavaScript
// Notes.js
function Notes($s) {
  const draft = $signal("draft" in $s ? $s["draft"] : "");
  function on_mount() {
    draft($py.or(localStorage.getItem("draft"), () => ""));
    $py.watch((() => (draft())), save);
  }
  function save(text) {
    localStorage.setItem("draft", text);
  }
  $onMount(on_mount);
  return [$h("textarea", {"$bind": draft})];
}
$mount("Notes", Notes);

Watches, subscribe(...) calls and other cleanups set up in on_mount stop when the page is left (see Layouts & navigation).

#Batching and ordering

All writes made synchronously in one event handler are batched: each affected binding updates once, after the handler returns (or reaches its first await, such as a server call). Reads inside the handler see the latest values immediately.

#Rendering model

  1. The server renders the page to HTML with real values.
  2. The page module hydrates it: it walks the server's DOM in order and attaches event handlers and live bindings to the existing nodes. No node is recreated or moved, so focus, the caret position and anything the user typed before the module loaded are kept, and text typed into a bound field is copied into its variable.
  3. From then on each signal write re-runs only the bindings that read it.

If the server HTML doesn't match what the browser code would render (for example, a value that formats differently in Python and JavaScript, or HTML the browser's parser restructures, like a <div> inside a <p>), hydration stops and the page is rendered from scratch on the client, as earlier versions always did. The browser console shows a warning that names the first difference, and the page root gets data-pw-mode="rendered" instead of "hydrated".

Edit this page on GitHub