Lazy Signals

In this library, there are signals and effects. Signals are containers that hold a single value. Effects are functions that are called when any signal they depend on changes its value.

Warning

Read this if you came to the impression that the library just implements the observer pattern.

Define Signals

There are two major ways to define a new signal:

  1. Define a variable-style signal by using the Signal constructor. You can pass in the initial value and update it by assigning to the value property.

  2. Build a signal from others by using the func() decorator. You write a function that uses the value property of other signals to compute a live value.

Top Level Signals

class Signal(value=None)

A container to hold a reactive value.

property value
Getter:

gets the current value, registering the current effect as a dependent

Setter:

sets the current value, notifying all dependent effects if it changed

You can create a signal by calling the constructor. Optionally, you can pass in the initial value. An instance of this class very much behaves like a normal variable: Simply use signal.value to access (get/set) its value. You must wrap your variables in this class to make them reactive.

Example:
from lazysignals import Signal

s = Signal(0)   # create a signal with initial value 0
print(s.value)  # prints '0'
s.value = 1     # update the value
print(s.value)  # prints '1'

Derived Signals

derived(fn)

Define a new signal whose value is computed by fn().

Parameters:

fn – A function to compute the value from.

Returns:

A new Signal.

Tip

Use as a function decorator.

Internally, this just defines an effect to update the derived signal via the provided function.

Example:
from lazysignals import Signal, derived

s = Signal(0) # create a signal with initial value 0

# compute `parity.value` via `s.value % 2`
# `parity` now depends on `s`
@derived
def parity():
   return s.value % 2

print(parity.value) # prints '0'
s.value = 1 # update the value of `s` (not `parity`)
print(parity.value) # prints '1'

Note

You do not need to define dependencies manually. However, they are only detected when they lie in the active code path. See the section on dependency detection for more details.

Tip

Only those signals are recomputed that may actually change. We call this lazy evaluation.

Define Effects

effect(fn)

Run fn() whenever (relevant) state changes.

Parameters:

fn – The function to run on state changes.

Returns:

The return value of fn().

Tip

Use as a function decorator.

The effect() decorator allows you to define a function that is called whenever one of the signals on which the decorated function depends changes.

Example:
from lazysignals import Signal, derived

s = Signal(0) # create a signal with initial value 0
t = 0         # create a normal variable with initial value 0

# define an effect that prints the value of `s` and `t`
# immediately prints "0, 0"
@effect
def printer():
   print(f"{s.value}, {t}")

s.value = 1 # prints "1, 0"
t = 1       # does not print anything
s.value = 1 # does not print anything
s.value = 2 # prints "2, 1"

The function fn is called immediately when the effect is defined. Moreover, it is called whenever any of the signals it depends on changes its value. Because of lazy evaluation, it is not called on assignments to other signals or those who do not actually change a value. For obvious reasons, the effect is not called when variables change that are not defined as signals.

Note

You do not need to define dependencies manually. However, they are only detected when they lie in the active code path. See the section on dependency detection for more details.

Tip

You can use the effect() as a function decorator. But you can also use as a function call, for example, effect(lambda: print(s.value)). The call returns the result of evaluating the function.

A Note on Dependency Detection

With effects and derived signals, you do not need to define dependencies manually. This is the magic with signals after all. However, they are only detected when they lie in the active code path. This means that if you mention a signal s in the function fn but do not make use of s.value, the dependency will not be detected. For example if we have two signals s and t where t.value evaluates to False and we let fn = lambda: None if t.value else s.value, then t but not s will be tracked initially. If, t.value now changes to True, causing an update call to fn for the derived signal, the new dependency on s will be recognized and respected down the line.

A Note on Exceptions

Whenever exceptions are raised in a derived signal or effect, they are collected and raised at the end of the current signal propagation phase. This exception takes the form of Exception(...list of raised exceptions...).