Add behavior and reactivity¶
This guide shows you how to make a tree interactive: running actions on events, binding controls to state, and computing props from state. All of it is authored in Python as data and runs in the browser — no per-interaction round-trip. For the underlying idea, see How spaday works.
Run an action when an event fires¶
Attach a declarative action to a DOM event with .on(event, action):
from spaday import by_id, Toggle
from spaday_webawesome import WaButton
WaButton().text("Details").on("click", Toggle(by_id("info"), "hidden"))
Toggle(target, prop) flips a boolean prop. Target an element with by_id("info") (an element whose
id is info) or this() (the element the event fired on). The other actions:
SetProp(target, prop, value)— set a prop to a value or expression.SetField(field, value)/ToggleField(field)— write / flip a reactive state field (see below).Sequence(a, b, …)— run several actions in order. Import it asActionSequencewhen anotherSequenceis already in scope.If(cond, then, els=None)— branch on a live condition.Emit(event, detail=None)— dispatch a custom DOM event.SendPatch,CallEndpoint,NamedJs— see below.
Reference live values in an action¶
Action values are expressions evaluated when the event fires:
from spaday import by_id, event_value, not_, SetProp
from spaday_webawesome import WaSwitch
# set the panel's `hidden` to the *negation* of the switch's new value
WaSwitch().on("change", SetProp(by_id("panel"), "hidden", not_(event_value())))
event_value()— the triggering control’s value (itschecked, elsevalue, else the event detail).event_prop(path)— a path read off the raw DOM event object itself (event_prop("clientX")for the pointer position,event_prop("shiftKey")for modifiers).event_closest(selector, path)— a path read off the closest ancestor of the event target matching a CSS selector (event.target.closest(selector)) —event_closest("[data-node-id]", "dataset.nodeId")reads the id of the group a click landed in, however deeply nested the actual target. An empty path returns the matched element itself, which isn’t a useful serializable value — pass apathto extract data.prop(target, name)— read a prop off a live element (handy as anIfcondition).lit(value)— a literal; a plain Python value is coerced to one automatically.
Bind a control to state¶
For state that outlives a single event, use the reactive signal store. Bind a prop to a named state
field with .bind(prop, field, mode=...):
from spaday_webawesome import WaSwitch
WaSwitch().bind("checked", "lamp", mode="two-way")
mode="one-way"(default) keeps the prop in sync with the field.mode="two-way"also writes the field back when the control changes.
Two controls bound to the same field stay in sync; a field changed anywhere updates every prop bound to it. Where the field lives depends on the host: in a notebook it is the widget’s state (notebook guide); on a server it is a transports model (transports guide).
A two-way binding writes state from a control’s own value. To write state from any event — e.g. a plain icon button flipping a theme flag, or “Clear” resetting a form’s fields — use the store-writing actions:
from spaday import SetField, Sequence, ToggleField
from spaday_webawesome import WaButton
WaButton().text("🌙").on("click", ToggleField("dark"))
WaButton().text("Clear").on("click", Sequence(SetField("symbol", ""), SetField("qty", 0)))
Compute a prop from state¶
To derive a prop rather than mirror a single field, use .compute(prop, expr) with a field
expression. It recomputes whenever any field it reads changes (one-way by nature):
from spaday import all_, eq, field, not_
from spaday_webawesome import WaButton, WaCallout
# disabled = not(enabled)
WaButton().compute("disabled", not_(field("enabled")))
# hidden unless mode == "advanced"
WaCallout().compute("hidden", not_(eq(field("mode"), "advanced")))
# ready = a and b
WaButton().compute("disabled", not_(all_(field("a"), field("b"))))
The field-expression helpers: field(name), lit(value), not_(e), eq(a, b), all_(*es) (AND),
any_(*es) (OR), cond(test, then, else) (a ternary — compute("theme", cond(field("dark"), "dark", "light"))), obj({name: expr}) (compose an object from sub-expressions), and arr(*exprs)
(compose a list — arr(field("path")) wraps a dynamic value in a list, e.g. for a tree’s
selected_paths). They compose.
Send a model edit or call an endpoint¶
Two actions intentionally reach beyond the browser:
from spaday import CallEndpoint, SendPatch, event_value
from spaday_webawesome import WaButton, WaSelect
# mutate a transports model field — the app routes the edit to the wire (server-authoritative)
WaSelect().on("change", SendPatch("chart", "type", event_value()))
# the one explicit server round-trip: a REST call
WaButton().text("Save").on("click", CallEndpoint("POST", "/save", body=event_value()))
The body can be any expression — use obj({name: field(name)}) to compose a whole request from state
fields, so a generated form POSTs declaratively with no handler:
from spaday import CallEndpoint, field, obj
WaButton().text("Send").on("click", CallEndpoint("POST", "/api/order", obj({"symbol": field("symbol"), "qty": field("qty")})))
By default the call is fire-and-forget. To react to the response — show a success message, surface a
422 validation error — pass result= (a state field name): on completion the runtime writes
{"status": <int>, "ok": <bool>, "body": <parsed JSON or text>} to that field, so the outcome drives
reactive UI like any other state:
from spaday import CallEndpoint, field, not_
from spaday.components.shell import Show
WaButton().text("Send").on("click", CallEndpoint("POST", "/api/order", obj({"symbol": field("symbol")}), result="sent"))
Show(not_(field("sent.ok")), WaCallout().compute("textContent", field("sent.body")))
For transient notices, the shell’s Toast (spa-toast) is the canonical error-reporting surface: a
fixed corner stack of notifications with info / success / danger tones that auto-dismiss
(timeout ms, default 5000; 0 keeps a toast until its close × is clicked). Invoke its notify
method from any action chain — Invoke(by_id("toasts"), "notify", obj({"message": lit("Saved"), "tone": lit("success")})) — or drive its bindable message prop from state: every non-empty write
enqueues a toast, with the tone prop read at enqueue time, so a result= failure surfaces with no
handler:
from spaday import cond, field, lit
from spaday.components.shell import Toast
toasts = Toast(tone="danger", id="toasts")
toasts.compute("message", cond(field("sent.ok"), lit(""), field("sent.body")))
SendPatch is usually unnecessary once you use a two-way binding (above) — the binding carries the
control→model edit declaratively. Reach for SendPatch for an imperative edit that
isn’t a simple control value. When several models share a page, a SendPatch("ns", field, value) is
routed into the ns-namespaced store (see transports).
Route, defer, and refresh subtrees¶
Show mounts one branch on one condition. When a page is really routing — one selected value, many
branches — use Switch: it keys a store field to named cases, and the runtime indexes straight to
the matching branch instead of evaluating a predicate per candidate:
Pass the condition first: Show(field("ready"), content). Existing code can keep using
Show(content, when=field("ready")) or the plain-field shortcut Show(content, field="ready").
from spaday.components.shell import Switch
Switch("selected", {"a/b": card_ab, "c/d": card_cd}, default=placeholder)
Cases mount and unmount like Show branches (real elements, not hidden ones); an unmatched value
falls back to default=, or renders nothing without one. Bind the field to the URL —
serve(page, url={"selected": "model"}), see Serving — and the Switch is a router:
/?model=a%2Fb opens that case, selecting pushes a history entry, and back/forward work.
A big page doesn’t have to ship every branch up front. Lazy serializes a placeholder and a src
URL; the real subtree (a component-tree JSON document, e.g. from to_node()) is fetched the first
time the branch activates — on mount, or when its when= condition first turns truthy — and cached
by src from then on:
from spaday import element, eq, field
from spaday.components.shell import Lazy, Switch
Switch("selected", {
path: Lazy(element("em", "loading…"), src=f"/card/{path}", when=eq(field("selected"), path))
for path in paths
}, default=placeholder)
The initial tree.json then carries one placeholder per branch instead of the branch itself — for a
catalog of hundreds of server-rendered detail cards, that is the difference between kilobytes and
megabytes on first paint.
Finally, RefreshTree covers “server state changed, re-render” for apps that don’t need a live wire:
it re-fetches the page’s tree.json (or an explicit url=) and diffs it into the mounted tree with
the core’s patch machinery, so unchanged nodes keep their identity and client state. Frame and inline
tree modes have no JSON tree URL, so pass url= when using RefreshTree with either one. The diff
descends into structural bindings: a changed branch inside a Show/Switch re-renders (including
the stored bodies of a Switch’s non-mounted cases), and every loaded Lazy body is re-fetched
from its src — swapped only if the payload actually changed. Actions in a
Sequence are awaited in order — CallEndpoint resolves before the next action runs — so mutate-
then-refresh is one chain:
from spaday import CallEndpoint, RefreshTree, Sequence
WaButton().text("Materialize").on("click", Sequence(
CallEndpoint("POST", "/materialize", result="mat"),
RefreshTree(),
))
Call component methods and browser side effects¶
Some interactions are a component method, not a prop — a layout’s openPanel(name), a viewer’s
resetView(). Invoke calls a declared method with evaluated arguments, fire-and-forget (an async
method’s rejection is logged, not thrown); the call expression is its synchronous, value-returning
sibling for methods you read from — and both stay data on the wire, with no eval:
from spaday import Download, Invoke, SetStorage, by_id, call, event_prop
# open the tab named by the clicked button's data-tab attribute
opener.on("click", Invoke(by_id("layout"), "openPanel", event_prop("currentTarget.dataset.tab")))
# persist a layout locally (strings store verbatim, other values JSON-encode)
save.on("click", SetStorage("my_layout", call(by_id("layout"), "save")))
# or hand it to the user as a file — no server round-trip
export.on("click", Download("layout.json", call(by_id("layout"), "save")))
An async method composes with Sequence: Invoke awaits a returned promise (and with result=,
writes the resolved value to a state field first), so “save, then persist what was saved” is plain
data — while purely synchronous action chains still apply in the same tick:
save.on("click", Sequence(
Invoke(by_id("workspace"), "saveClean", result="custom_layout"),
SetStorage("my_layout", field("custom_layout")),
))
Prefer methods a component’s manifest declares — they are part of its public surface. Method names are not yet checked against the catalog at authoring time (a mistyped name logs a console error at event time); catalog-backed validation is planned alongside CEM method parsing.
The escape hatch¶
For the rare irreducible case, NamedJs("handler") invokes a JavaScript handler you pre-registered in
the browser with registerHandler("handler", fn). It calls by name — never eval — so the safety
property holds.
Validate references before shipping¶
A by_id("typo") that points at no element does nothing at runtime, silently. Catch it at authoring
time:
import spaday
spaday.validate(tree) # raises ValidationError listing any unresolved by_id reference
Prop values are checked at authoring time too: a CEM-generated component given a literal that
contradicts its declared catalog kind — a str for a number prop, a number for an enum — raises
a TypeError naming the component tag, the prop, and the offending value, instead of surfacing later
as an opaque component error in the browser. Two limits: reactive bindings and computed values are
dynamic and stay unvalidated, and json-kind props (lists, objects, mixed unions, untyped props all
map to that kind) are type-opaque, so no literal shape can be ruled out for them by kind alone — see
set_strict_props below for the check that reads their declared type instead.
Prop names are checked too. A schema-carrying component’s constructor accepts the snake_case
spelling of each camelCase CEM prop and normalizes it to the canonical name —
Dagre(max_label_width=180) sets maxLabelWidth; passing both spellings at once raises. And
spaday.validate checks every node that still knows its catalog schema for unknown prop and binding
names: a typo raises a ValidationError naming the tag and prop, with a “did you mean” hint when the
unknown name is the snake_case spelling of a real prop. A component’s known names are its attributes
and its property-only fields (schema.fields — see the manifest guide), so a data
component’s payload keyword passes. Generic globals (id, class, style, slot, data-* /
aria-*, …) always pass, and so do the root-class: / root-attr: bindings, which name a class or
attribute on <html> rather than a prop of the element they are authored on. On a serialized dict tree, schemas resolve by tag
instead — every schema-carrying class that has been imported registers its tag — so
validate(component.to_node()) checks the same props as validate(component). Nodes with no
schema either way (element(...) escape hatches) stay unvalidated, as do two-way binding names,
which target live form-control properties a manifest routinely omits.
Name checking is a validate call by default, not a constructor error, because a manifest describes an
element’s inputs approximately — schema.fields is filtered from a class surface that mixes real inputs
with plumbing, so a name spaday does not recognize is not proof of a mistake. Where the earlier error is
worth that risk, turn it on with set_strict_props:
from spaday import Component
from spaday_webawesome import WaSelect
Component.set_strict_props() # every schema-carrying class, catalogs you don't own included
WaSelect.set_strict_props(False) # ...except one the manifest describes badly
An unknown keyword then raises TypeError at construction, naming every unknown name at once in the
same words validate uses. Strict components also check the shape of a json prop against the type
its manifest declares — schema.type_text, carried for exactly the kind that says nothing else about
shape:
Heatmap(data=[[1, 2], [3, 4]])
# TypeError: <spa-heatmap> prop 'data' expects { rows: string[]; cols: string[]; values: number[][] },
# got [[1, 2], [3, 4]] (list)
That is the mistake worth catching early: the name is right, the shape is wrong, and the browser reports
it as a setter throwing far from the call that caused it. Only the spellings that can mean nothing else
are read — an array (T[], Array<T>) and an object ({…}, Record<…>). A named type like
HeatmapData is deliberately not read, because an alias can name either shape; the declared text is on
the schema, so an author who knows their own types can assert on it. A class that sets it explicitly keeps that setting when a base class is
toggled later, so the opt-out above survives. Only constructor keywords are checked — .prop(name, value) stays the explicit way to set an attribute the manifest does not describe.