Skip to content

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).
  • equals decides when observers are notified. The default is an identity fast path followed by ==; equals=False always notifies; a callable (old, new) -> bool skips notification when it returns True (use lambda a, b: a is b for 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 (None on the first run).
  • lazy=True suspends 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 def body (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:

  • compute may accept a positional parameter to receive its previous return value.
  • compute may be async def; awaits suspend the effect without blocking, and reads after an await are still tracked.
  • defer=True skips the first apply (tracking still starts).
  • error=handler receives exceptions from compute instead of routing them to the nearest Errored boundary.
  • The returned Computation (from wybthon.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 For row: 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 a Prop[T] field on a Props class; use prop(default_factory=...) for mutable defaults.
  • merge(*sources) merges props instances, dicts, and zero-arg functions returning dicts into one reactive mapping; later sources win, and None is a real value.
  • omit(source, *keys) returns a reactive view without the given keys, or without every key a predicate accepts (the replacement for split_props).
  • children(fn) resolves a children prop into a ChildrenAccessor: nested accessors are resolved, lists are flattened, and None and 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_id returns a process-unique string for id/for pairs.
  • is_accessor reports whether a value is a reactive expression (an Accessor or a zero-arg function); it's the rule the framework uses to decide what becomes a hole.
  • flush settles the graph now. Automatic in the browser; call it in tests.

Next steps