Primitives¶
Wybthon uses a signals-first reactive model matching SolidJS 2.0. Component bodies run once; reactivity comes from accessors read inside reactive holes, memos, and effects. This page is the reference for each primitive. Reactivity explains how they fit together and when the graph flushes.
Reactive holes¶
A reactive hole is a reactive expression (an
Accessor or a zero-arg function) placed in a VNode
tree, as a child or as a prop value. The reconciler runs it inside its
own render effect, so the component body runs once while the hole
patches its region of the DOM whenever its dependencies change.
from wybthon import component, create_memo, create_signal, hole
from wybthon.html import button, div, p, span
@component
def Demo():
count, set_count = create_signal(0)
doubled = create_memo(lambda: count() * 2)
return div(
# 1) An accessor as a child.
p("Count: ", span(count)),
# 2) A zero-arg expression as a child.
p(lambda: f"Doubled: {count() * 2}"),
# 3) A template string: one binding for the whole text.
p(t"Count is {count}, doubled {doubled}"),
# 4) The explicit form, when you need a key.
p(hole(lambda: f"Tripled: {count() * 3}", key="triple")),
# 5) A reactive prop value (any prop except event handlers and ref).
p("Status", class_=lambda: "danger" if count() > 5 else "ok"),
button("+1", on_click=lambda: set_count(lambda n: n + 1)),
)
A PEP 750 template string
(Python 3.14) is a reactive expression wherever text is accepted, as a
child or as an attribute value (class_=t"card card-{variant}").
Interpolated accessors and zero-arg functions are called inside one
binding, so the whole string updates together; other interpolations are
formatted once, and a template string with nothing reactive in it is
static text.
A hole's expression may return a string or number (a text node), a
VNode, a list of either (mounted as a fragment), None (nothing), or
another accessor (nested hole).
Holes are ownership scopes. Use on_cleanup
inside one to register teardown that runs before each re-evaluation and
on disposal:
from wybthon import on_cleanup
def subscribe(topic_name):
handle = open_subscription(topic_name)
on_cleanup(handle.close)
return f"listening to {topic_name}"
p(lambda: subscribe(topic())) # re-subscribes when topic changes
create_signal¶
create_signal returns (getter, setter),
typed as Accessor[T] and Setter[T].
from wybthon import create_signal, flush
count, set_count = create_signal(0)
set_count(5)
count() # 0: the write is staged
flush() # automatic in the browser
count() # 5
set_count(lambda n: n + 1) # functional update sees staged values
count.peek() # 5: untracked read of the committed value
- Writes are staged. The setter records the value; reads keep returning the committed value until the next flush. See Staged writes.
- Functional updates receive the latest staged value, so two
set(lambda n: n + 1)calls in one handler add two. To store a callable as the value, return it from an updater:set_fn(lambda _: my_callable). equalsdecides when observers are notified. The default is an identity fast path followed by==;equals=Falsealways notifies; a callable(old, new) -> boolskips notification when it returnsTrue(uselambda a, b: a is bfor identity-only semantics).- Function form.
create_signal(lambda: a() + b())returns a writable derived signal: it tracks what the function reads, and the setter overrides the value until the next source change.
Signals created in a component body live for the component's lifetime. There are no hook rules.
create_memo¶
create_memo returns a Memo, a
read-only accessor for a derived value.
from wybthon import create_memo
doubled = create_memo(lambda: count() * 2)
doubled() # tracked read
doubled.peek() # untracked read
- Eager setup, pull-based updates. The body evaluates at creation unless
lazy=True. After an input changes, reads bring the memo current. - Glitch-free. Reading a memo brings its sources current first, so a diamond of memos never observes a half-updated state.
- Equality short-circuits. Observers are notified only when the value changed under
equals. - Previous value. If the body accepts a positional parameter it receives the previous value (
Noneon the first run). lazy=Truesuspends the memo once it loses its last subscriber; a later read recomputes it;unobserved=runs a callback at that moment (for resource cleanup).- Async bodies. An
async defbody (or an async generator) makes the memo an async computation. See Async and loading.
create_effect¶
create_effect creates a side effect that
re-runs when its tracked sources change. Effects run after the DOM
commit in each flush, and the first run happens on the next flush,
right after the component that created it has mounted.
Both stages are required: compute runs tracked and returns a
value; apply runs untracked with (value, prev) (or (value,)) and
performs the side effect. Writes belong in apply.
from wybthon import create_effect
create_effect(count, lambda value, prev: print(prev, "->", value))
create_effect(lambda: (a(), b()), lambda pair: print("changed:", pair))
apply may return a cleanup callable that runs before the next apply
and on disposal:
def start(interval_ms):
handle = set_interval(tick, interval_ms)
return lambda: clear_interval(handle)
create_effect(interval, start)
The explicit tracked effect create_tracked_effect(fn) makes fn both the tracking
stage and the side effect. Use on_cleanup for
per-run teardown. Writing a signal inside it raises
WriteInScopeError in dev mode.
Other options:
computemay accept a positional parameter to receive its previous return value.computemay beasync def; awaits suspend the effect without blocking, and reads after anawaitare still tracked.defer=Trueskips the firstapply(tracking still starts).error=handlerreceives exceptions fromcomputeinstead of routing them to the nearestErroredboundary.- The returned
Computation(fromwybthon.reactivity) has.dispose().
create_render_effect¶
create_render_effect takes the same
arguments as create_effect, including the required apply stage, but
runs in the render phase, before the DOM commit, and its first run
happens immediately at creation. Holes and prop bindings are render
effects. Reach for it only when building a rendering primitive;
create_effect is right for application code. For a single tracked
function, use create_tracked_effect.
create_reaction¶
create_reaction separates tracking from
re-execution. It returns track(fn): calling track runs fn and
subscribes to what it reads, and the first time any of those sources
changes, the effect runs once (untracked, after the DOM commit) and the
subscription ends. Call track again to re-arm it.
from wybthon import create_reaction
track = create_reaction(lambda: print("count changed"))
track(count) # arms the reaction
set_count(1) # prints once on the next flush
set_count(2) # silent until track() is called again
untrack and .peek()¶
untrack runs a function with tracking suppressed.
Every accessor also has .peek() for a single untracked read.
from wybthon import create_effect, untrack
create_effect(lambda: (a(), untrack(b)), lambda pair: print(pair)) # depends on a only
count.peek() # the same idea for one read
Both silence the dev-mode top-level-read warning, so they're the explicit way to seed local state from a prop.
on_settled¶
on_settled runs a callback once the flush that
mounted the current component has committed, so refs are assigned and
the DOM is live. It may return a cleanup that runs on unmount.
from wybthon import Prop, Props, Ref, component, on_settled
from wybthon.html import canvas
class ChartProps(Props):
data: Prop[list[float]]
@component
def Chart(props: ChartProps):
ref = Ref()
def start():
handle = draw(ref.current, props.data.peek())
return lambda: handle.destroy()
on_settled(start)
return canvas(ref=ref)
on_cleanup¶
on_cleanup registers teardown on the active
scope:
- In a component body: runs when the component unmounts.
- In a tracked effect or compute stage: runs before recomputation and on disposal. In a split effect's apply stage: runs before the next visible apply and on disposal.
- In a hole or
Forrow: runs when that region is re-evaluated or removed.
It raises RuntimeError outside any scope.
create_root, get_owner, and run_with_owner¶
create_root runs a function inside a new,
ownership root and hands it a dispose callable. The root belongs to its current owner; detached=True creates an independent lifetime. Use it for
long-lived reactive work that shouldn't die with a component, such as
global stores.
from wybthon import create_effect, create_root
def setup(dispose):
create_effect(count, lambda value: print("count", value))
return dispose
dispose = create_root(setup)
dispose() # tears the effect down
await drops the active owner. Capture it with
get_owner before suspending and restore it with
run_with_owner when creating primitives
afterwards:
from wybthon import get_owner, run_with_owner
async def later():
owner = get_owner()
await something()
run_with_owner(owner, lambda: create_effect(count, lambda value: print("count", value)))
Typing¶
Every read goes through Accessor[T]: signal
getters are Signal[T], memos are Memo[T], and
reading a Prop[T] field on a component's props returns
an Accessor[T]. Setters are
Setter[T]. Annotate function parameters as Accessor[T] when they
accept any of these. See the Typing guide.
Props helpers¶
prop(default=...)declares the default of aProp[T]field on aPropsclass; useprop(default_factory=...)for mutable defaults.merge(*sources)merges props instances, dicts, and zero-arg functions returning dicts into one reactive mapping; later sources win, andNoneis a real value.omit(source, *keys)returns a reactive view without the given keys, or without every key a predicate accepts (the replacement forsplit_props).children(fn)resolves a children prop into aChildrenAccessor: nested accessors are resolved, lists are flattened, andNoneand booleans are dropped. Calling it returns the single child or a list;.to_array()always returns a list.
from wybthon import merge, omit
attrs = merge({"type": "button"}, omit(props, "label", "children"))
handlers = omit(props, lambda key: not key.startswith("on_"))
map_array¶
map_array maps a reactive list to values with a stable
owner scope per row. keyed selects how rows are matched, and with it
the callback shape. For uses mounted list regions with the same matching modes
and can consume store edit records directly:
keyed |
Rows matched by | fn(item, index) receives |
|---|---|---|
True (default) |
identity (scalars by value) | raw item, Accessor[int] |
False |
position | Accessor[T], int |
key(item) callable |
key | Accessor[T], Accessor[int] |
from wybthon import create_signal, map_array
items, set_items = create_signal(["A", "B", "C"])
by_identity = map_array(items, lambda item, idx: f"{idx()}: {item}")
by_position = map_array(items, lambda item, idx: f"[{idx}] {item()}", keyed=False)
by_key = map_array(rows, lambda row, idx: f"{idx()}: {row()['title']}", keyed=lambda r: r["id"])
Row bodies run untracked inside the row's owner, so anything reactive
inside a row must read an accessor within a hole, memo, or effect.
fallback= supplies a single row for the empty list.
repeat¶
repeat maps an integer range to rows, each with its
own owner scope. fn(i) runs once per slot for i in
range(start, start + count); growing the count maps only the new
slots, and shrinking it disposes the removed ones. The Repeat
component is built on it.
from wybthon import create_signal, repeat
rating, set_rating = create_signal(3)
stars = repeat(rating, lambda i: f"star {i}", start=1)
stars() # ["star 1", "star 2", "star 3"]
count and start may be plain ints or accessors, and fallback=
supplies a single row when the count is zero.
Selection with create_projection¶
To mark one selected row without notifying every row, derive a
projection keyed by the selection with
create_projection. A projection notifies
only the keys it changes, so a new selection updates the row that was
selected and the one that was deselected:
from wybthon import create_projection, create_signal
selected, set_selected = create_signal(1)
is_selected = create_projection(lambda: {} if selected() is None else {selected(): True})
li("Item 2", class_=lambda: "active" if is_selected.get(2) else None)
is_selected.get(key) is tracked per key and returns None when the
key is absent. See Stores for projections in general.
Miscellany¶
create_unique_idreturns a process-unique string forid/forpairs.is_accessorreports whether a value is a reactive expression (anAccessoror a zero-arg function); it's the rule the framework uses to decide what becomes a hole.flushsettles the graph now. Automatic in the browser; call it in tests.
Next steps¶
- Read Reactivity for flush timing and the ownership tree.
- See Lifecycle and ownership for disposal order.
- Browse the
reactivityAPI reference.