Inside a Plots notebook, cells will automatically rerun in response to a change in widget values. This feature is powered by a generic reactivity system that can be used to encode aribtrary data dependencies between parts of a notebook’s code.

Introduction

The reactive system is based on two primitive objects:

  • signals, which store a reactive value,
  • and computation nodes, which store a reactive function.

Computation nodes that read a signal value will be automatically subscribed to the signal. Later, when a signal value changes, all its subscribers get re-executed.

This is how the familiar example of widget reactivity works:

  1. Widgets are created alongside a signal for their value.
  2. Cells are implicitly wrapped up in computation nodes.
  3. When a cell runs, it may access a widget value through the signal. If it does, the signal records that the cell’s computation node needs to rerun when the value changes.
  4. User input causes the UI to send an update to the kernel, which updates the widget value signal, which reruns all of its subscribers.

Custom Signals

Signals can be created at any time, independent of any widgets.

from lplots.reactive import Signal

value = Signal(10)

To read the signal’s value, call it with no arguments. This will subscribe the current computation node to the signal:

x = value()
# now subscribed to changes in `value`

print(f"value = {x}")

To update the signal’s value, call it with the new value as an argument:

value(10)
# the previous cell will automatically rerun

The signal stores a reference to the value and will not react to changes within the stored object. It is best practice to treat signal values as immutable:

obj = Signal({})

# Bad practice:
# mutating a signal value
#
obj()["hello"] = "world"
# *does not* cause an update

# Best practice:
# treating the signal value as immutable and copying it
#
obj({**obj(), "hello": "world"})

Avoid infinite update loops by making sure a cell never unconditionally updates a signal that it is subscribed to:

x = value()
# now subscribed to changes in `value`

value(x + 10)
# !!! Infinite update loop
# cell 4 will
# 1. subscribe to changes in `value`,
# 2. cause a change in `value`,
# 3. rerun because `value` changed,
# 4. cause a change in `value`,
# 5. rerun because `value` changed,
# ...

if x < 50:
  value(x + 10)
  # OK: the update loop will stop whenever `value` reaches 50

.sample() allows reading a signal value without subscribing to its changes:

x = value.sample()
# this cell will *not* rerun automatically

print(f"value = {x}")

Reactive Transactions

Signal updates are delayed until the current cell finishes running all the way through:

## =-= <cell 1> =-=

value = Signal(10)
value_str = Signal("10")

x = value.sample()
assert x == 10

value(20)
value_str("20")

x = value.sample()
assert x == 10
# the value did not change yet!

value(30)
value_str("30")
# this update will override the previous one

## =-= </> =-=


## =-= <cell 2> =-=
assert str(value.sample()) == value_str.sample()
## =-= </> =-=


## After the cell 1 finishes:
assert value.sample() == 30
assert value_str.sample() == "30"

This somewhat unintuitive behavior is a best practice established in reactive systems design as it helps keep the overal state of the system consistent at all times.

In the previous example value and value_str are obviously related. The delayed updates ensure that the relationship is preserved at all times—cell 2 will never trigger an AssertionError.

Contrast this with a hypothetical system that applies updates immediately.

...
value(30)
# Causes cell 2 to rerun immediately:
#
# assert str(value.sample()) == value_str.sample()
# !!! AssertionError:
# !!! value.sample() == 30
# !!! value_str.sample() == "20"

value_str("30")
...

A reactive transaction can actually include more than one cell. If a signal update causes multiple cells to rerun, all of these cells will belong to a transaction:

# value = Signal(1)

# cell_a:
a(value() + 10)

# cell_b:
b(value() * 2)

# cell_print:
print(f"cell_print {a()}, {b()}")

# Run all the above cells.

# Add and run a new cell:
# cell_main:
value(20)

# Update order:
# Transaction 1:
#   1. cell_main
#
#   Apply signal updates:
#   value = 20
#     reruns cell_a, cell_b
#
# Transaction 2:
#   1. cell_a
#   2. cell_b
#
#   Apply signal updates:
#   a = 30
#     reruns cell_print
#   b = 40
#     reruns cell_print
#
# Transaction 3:
#   1. cell_print

# Program output:
# cell_print 11 2
# cell_print 30 40

Note that we do not see any of the intermediate states e.g. cell_print 30 2, cell_print 11 40; which could be printed in an immediate-update system.

Reactivivity with Conditionals

Each time a cell reruns, it starts from a fresh state and will forget all its previous subscriptions. This means that a cell is only subscribed to the signals it access during its last run.

Cells with conditionals can change their subscriptions on each run:

# value = Signal(10)
# value_str = Signal("10")

if value() > 30:
  # will not run as 10 < 30

  x = value_str()
  print(f"value_str = {x}")

# this cell subscribes to `value` but *not* `value_str`

If the value of value changes to 50, the cell will execute the conditional and thus access and subscirbe to value_str.

If necessary, the cell can always uncoditionally subscribe to a signal by just accessing its value and not using it:

value_str() # subscribe to `value_str`

if value() > 30:
  ...

Redefining Signals

In Python, setting a variable will dispose of the old value entirely:

x = {"subscribers": []}
x["subscribers"].append("cell 1")

x = {"subscribers": []}
assert len(x["subscribers"]) == 0

This is annoying with signals, as the cell defining the signal can rerun and then the signal would be recreated with no subscribers. There is a special rule in Plots notebooks that global variables holding a signal will not be disposed of when overriding them. Instead, the signal value will be updated:

# value = Signal(10)
# subscribers: cell 1

value = Signal(20)
# equivalent to `value(20)`
# will keep the old subscribers

# value = Signal(20)
# subscribers: cell 1

To replicate the default behavior and throw out the old signal, use del:

# value = Signal(10)
# subscribers: cell 1

del value
# old signal is gone, including its subscriber list

value = Signal(20)
# no subscribers

When using local variables, no special rule applies. To avoid overriding a signal in a local variable, check locals():

if "value" not in locals():
  value = Signal(10)

Cheatsheet

  1. x = Signal(initial) defines a new signal with value set to initial
  2. x() reads the value of the signal and subscribes
  3. x(10) sets the value of the signal
  4. x.sample() reads the value of the signal without subscribing. Useful to avoid infinite update loops
  5. Signal updates are delayed until the current transactions ends
  6. Each time, all updated cells execute in one transaction
  7. Cells only subscribe to signals they accessed during the last run
  8. Signal values should be treated as immutable
  9. Re-assigning a signal to a global variable will override the value but not clear subscribers