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
| Kind | Rule | In the browser |
|---|---|---|
| signal | changed 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 |
| computed | assigned once from an expression that reads signals, never changed by a handler | a cached derivation, recomputed only after one of its inputs changes |
| constant | anything else | a plain value |
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
// 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);page Cart route=/
browser price constant
browser quantity reactive state: bound to an input (line 13); literal initial value
browser total derived from quantity; recomputed when they change
browser label constant<label>Quantity <input type="number" value="1"></label><p>Total: 25</p>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:
| Origin | Example | Behaviour |
|---|---|---|
| literal | count = 0 | known at compile time |
| browser | total = price * quantity | computed by the generated JavaScript |
| server | rows = load_rows(), user = session.user(), anything after page-level Python logic (if, loops), anything derived from another server value | computed 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.
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
// 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);page Board route=/
browser cards reactive state: updated in vote(); literal initial value
browser selected reactive state: assigned in vote(); literal initial value
browser vote event handler (compiled to JavaScript)
browser sort_cards event handler (compiled to JavaScript)<ul>
<li>a: 0</li>
</ul>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:
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
// 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);page Notes route=/
browser draft reactive state: bound to an input (line 18); literal initial value
browser on_mount event handler (compiled to JavaScript)
browser save event handler (compiled to JavaScript)<textarea></textarea>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
- The server renders the page to HTML with real values.
- 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.
- 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".