Signals#

A Signal is a reactive value. Bind one to a widget and the two stay in sync automatically: when the user edits the widget the signal updates, and when you call signal.set(...) the widget redraws. Signals are how you connect application state to the interface without wiring up change callbacks by hand.

Creating a signal#

Construct a Signal with its initial value. The value’s type is fixed at creation — passing a string makes a text signal, an int makes a numeric one, a bool makes a boolean one:

import bootstack as bs

name = bs.Signal("World")     # text signal — fine at module level
count = bs.Signal(0)          # integer signal
enabled = bs.Signal(True)     # boolean signal

with bs.App() as app:
    bs.TextField(textsignal=name)
app.run()

Note

Signals can be created at module level, before bs.App() exists. The backing Tk variable is created lazily on the first widget binding and is transparent to callers — signal(), set(), subscribe(), and map() all work before any App is running.

Reading and writing#

Call the signal to read its current value. Use set() to update it:

name()              # "World"  — call to read
name.set("Universe")

set() enforces the signal’s type. Assigning a value of a different type raises TypeError — a Signal(0) accepts set(5) but rejects set(1.5). The one exception is numeric widening: a float signal also accepts an int (bs.Signal(0.0).set(5) stores 5.0). Setting the same value the signal already holds is a no-op and does not notify subscribers.

Binding to widgets#

Pass a signal to a widget to create a two-way binding. Text-bearing widgets use textsignal=; boolean and numeric widgets use signal=:

name = bs.Signal("World")   # defined at module level

with bs.App(gap=8) as app:
    bs.TextField(textsignal=name)
    bs.Label(textsignal=name)         # mirrors the field as you type
    bs.Button("Greet", on_click=lambda: print(f"Hello, {name()}!"))
app.run()

Typing in the field updates name; calling name.set(...) updates the field. The same signal can drive several widgets at once, keeping them all consistent.

See also

textsignal= is for widgets that carry text (TextField, TextArea). signal= is for widgets that carry a typed value — boolean, numeric, date, and time (Checkbox, Slider, NumberField, DateField, TimeField). A value-bound signal carries the typed value itself: a DateField signal= reads back a date, not a string.

Typed value signals#

A signal is not limited to text. signal= binds the widget’s typed value, so a date, time, or number round-trips as itself — never as a string. Bind a Signal holding a date to a DateField, and reading the signal back gives you a date:

from datetime import date, timedelta

due = bs.Signal(date(2026, 1, 15))   # defined at module level

with bs.App(gap=8) as app:
    bs.DateField(signal=due)
    bs.Button("Next day", on_click=lambda: due.set(due() + timedelta(days=1)))
app.run()

due()              # datetime.date(2026, 1, 15) — a date, not "2026-01-15"

To show a typed value as text — a caption or summary label — derive a text signal with map() and bind it with textsignal=:

due = bs.Signal(date(2026, 1, 15))
due_text = due.map(lambda d: d.strftime("%b %d, %Y"))

bs.Label(textsignal=due_text)      # "Jan 15, 2026", re-derived on every change

A transform runs on whatever the source holds, so one whose source can be empty needs a little more care.

Empty values#

A signal holds one type, decided by the value it is created with — so by default it has no way to say “nothing”. A field bound to such a signal keeps its last value when it is cleared, rather than reporting the clear.

Pass allow_empty=True when the value can also be empty, and use clear() to empty it — the same verb the fields themselves use:

due = bs.Signal(date(2026, 1, 15), allow_empty=True)

bs.DateField(signal=due)

due.clear()         # allowed — subscribers are notified
due()               # None

A signal that allows an empty value may also start empty. There is no value to take a type from, so name it with dtype:

due = bs.Signal(None, allow_empty=True, dtype=date)
due.type            # <class 'datetime.date'>
due()               # None

due.set(date(2026, 1, 15))
due.set(7)          # TypeError — a signal holds one type, empty or not

dtype is honored whenever it is given, so a seed that may or may not be there needs no second spelling — and a seed that contradicts it is reported where the two disagree rather than at some later write:

due = bs.Signal(record.get("due"), allow_empty=True, dtype=date)

bs.Signal(5, allow_empty=True, dtype=str)     # TypeError at construction

Clearing a bound field now reaches the signal, in both directions:

field = bs.DateField(signal=due)

field.value = None  # due() is None, and subscribers are notified
due.clear()         # the field is emptied

What “empty” means#

Empty is None — except where the signal is the widget’s own variable, as it is for a text field or a radio group. A variable holds only strings, so there empty is "":

name = bs.Signal("Ada", allow_empty=True)
bs.TextField(textsignal=name)
name.clear()
name()              # "" — this signal is the field's variable

pick = bs.Signal("1", allow_empty=True)
bs.Select(options=[("One", "1"), ("Two", "2")], signal=pick)
pick.clear()
pick()              # None — a Select's signal carries the option's value

Both signals hold strings; what differs is where the value lives. Bind that same pick signal to a bs.Label as well and it empties to "" too — it is the label’s variable now. Prefer a falsiness check — if not pick(): — which reads the same either way.

A signal holding a set, as a multi-select bs.ToggleGroup does, is the one exception: it empties to the empty set wherever it is bound, because an empty set is a real value of the type rather than a stand-in for one. A falsiness check covers that too.

Deriving from a signal that can be empty#

map() calls the transform with whatever the source holds, and that includes the empty. Handle it, or the transform runs against nothing:

due = bs.Signal(date(2026, 1, 15), allow_empty=True)

due.map(lambda d: d.strftime("%b %d, %Y"))              # AttributeError on clear()
due.map(lambda d: d.strftime("%b %d, %Y") if d else "")  # correct

Return a value for the empty case, never None. A derived signal is an ordinary one — nothing declared it able to be empty — so returning None raises out of the transform. Return the empty of the type you are deriving: "" above, because the result is text and is bound with textsignal=.

A signal that cannot be empty needs none of this, which is why the earlier map() example has no check.

Note

Binding a signal that allows an empty value to a checkbox, switch, toggle button, slider or progress bar raises. Those widgets keep their value in the signal’s own variable, and a boolean or numeric variable has no way to hold an empty one — False and 0 are real values, not absent ones. A tristate checkbox is the exception worth knowing about: it does have a third state, but it holds that state in the widget rather than in the variable, so a bound signal cannot report it. Read it from the checkbox’s value.

Reacting to changes#

Subscribe a callback to run whenever the value changes. The callback receives the new value, and subscribe returns a cancellable handle:

sub = count.subscribe(lambda value: print(f"count is now {value}"))

count.set(1)        # prints "count is now 1"

sub.cancel()        # stop listening

The handle is also a context manager, so a subscription can be scoped to a block and cancelled automatically on exit:

with count.subscribe(on_change):
    ...             # listening here
# cancelled on exit

Pass immediate=True to fire the callback once with the current value at subscription time, in addition to future changes:

count.subscribe(update_total, immediate=True)

Derived signals#

map() returns a new, read-only signal whose value is computed from the source. It recomputes automatically whenever the source changes:

name = bs.Signal("world")
shout = name.map(str.upper)

shout()             # "WORLD"
name.set("hello")
shout()             # "HELLO"

Note

A derived signal is held weakly by its source. Keep a reference to it — assign it to a variable or bind it to a widget. If it is garbage-collected it silently stops updating.

See also#

  • Events — the subscribe / Stream model for widget events.

  • TextField — an input widget that accepts textsignal= / signal=.

API reference#

The complete reference — every method on Signal — lives on the Reactivity API page (Signal is part of the top-level compose surface). At a glance:

Signal

A reactive value that widgets can bind to.