Reactive Functions and Expressions#
In Param, multiple paradigms for dynamic behavior coexist. The Dependencies and Watchers guide delves into an imperative ‘push’ model, where explicit callbacks handle Parameter updates, often suited for GUI environments where user interactions drive state changes.
Param 2.0 introduces a declarative, reactive model. Reactive expressions automatically update when their referenced Parameters change. This model encourages you to specify ‘what’ should happen, letting Param manage ‘how,’ thereby simplifying code logic and enhancing modularity. Unlike the ‘push’ model, which may require complex event orchestration, the reactive model emphasizes high-level relationships. This makes it versatile, scaling from simple to complex use cases. For instance, the reactive model is a good fit in data transformation pipelines and real-time dashboards, automatically updating dependent steps or visualizations when underlying Parameters change. The reactive approach allows you to focus on defining the transformation or relationship logic without worrying about the sequence of updates.
This guide covers two main approaches to the reactive model:
Reactive Expressions: With
.rx
, create reactive proxies for Parameters or objects, which recompute as inputs change.Reactive Functions: Using
.bind
, auto-invoked functions update when their inputs change, offering a more declarative alternative to.watch()
.
Note
The code in this guide is designed to be run incrementally to observe the behavior of reactive expressions. If you’re reading a rendered version online, keep in mind that the entire page will have been executed, affecting the output of earlier lines. To fully experience the live updates, download this page as a Jupyter Notebook and run through it line by line.
Getting Started#
Param’s rx
feature allows you to create reactive values and expressions, enabling immediate updates to your results as you interactively modify values, avoiding the need for explicit callbacks or managing state manually.
Before we dive in to discover how rx
works behind the scenes, let’s get started with a concrete example of loading some data into a Pandas DataFrame and then displaying it:
import pandas as pd
import param
from param import rx
URL = 'https://datasets.holoviz.org/penguins/v1/penguins.csv'
nrows = rx(2)
df = rx(pd.read_csv(URL))
df.head(nrows)
species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | year | |
---|---|---|---|---|---|---|---|---|
0 | Adelie | Torgersen | 39.1 | 18.7 | 181.0 | 3750.0 | male | 2007 |
1 | Adelie | Torgersen | 39.5 | 17.4 | 186.0 | 3800.0 | female | 2007 |
2 | Adelie | Torgersen | 40.3 | 18.0 | 195.0 | 3250.0 | female | 2007 |
3 | Adelie | Torgersen | NaN | NaN | NaN | NaN | NaN | 2007 |
4 | Adelie | Torgersen | 36.7 | 19.3 | 193.0 | 3450.0 | female | 2007 |
5 | Adelie | Torgersen | 39.3 | 20.6 | 190.0 | 3650.0 | male | 2007 |
6 | Adelie | Torgersen | 38.9 | 17.8 | 181.0 | 3625.0 | female | 2007 |
7 | Adelie | Torgersen | 39.2 | 19.6 | 195.0 | 4675.0 | male | 2007 |
8 | Adelie | Torgersen | 34.1 | 18.1 | 193.0 | 3475.0 | NaN | 2007 |
9 | Adelie | Torgersen | 42.0 | 20.2 | 190.0 | 4250.0 | NaN | 2007 |
10 | Adelie | Torgersen | 37.8 | 17.1 | 186.0 | 3300.0 | NaN | 2007 |
Here, this is just the same code you’d normally use to make a DataFrame, except for rx()
being used to mark nrows
and df
as being reactive. As you can see, the reactive DataFrame works like any other DataFrame, using .head()
and any other DataFrame methods as usual. But now, let’s see what happens if we update the value of nrows
:
nrows.rx.value += 2
Whoa! As long as you are running a Jupyter notebook with a live Python process, you should have seen the dataframe “head” output in the previous cell update to the new value of nrows
. That’s because the reactive df
expression being displayed in that cell captures the full pipeline of operations, automatically re-running head
because the nrows
has now changed.
We’ve done this without having to write any special callbacks or any new functions, instead using special Python objects that capture the operations you’ve invoked and replay them as needed when inputs change.
These updates should happen immediately (not only when the code cell finishes executing):
import time
for i in range(5,10):
nrows.rx.value = i
time.sleep(1)
You should see the previous df.head
output react to each time nrows
is changed, updating to reflect the current state.
Next, let’s explore a more intricate example. Although it involves a more complex pipeline, the code remains similar to what you’d write for a non-reactive Pandas DataFrame. To confirm, you can simply remove the rx
calls:
import numpy as np
style = rx('color: white; background-color: {color}')
color = rx('darkblue')
def highlight_max(s, props=''):
if s.dtype.kind not in 'f':
return np.full_like(s, False)
return np.where(s == np.nanmax(s.values), props, '')
styled_df = df.head(nrows).style.apply(highlight_max, props=style.format(color=color), axis=0)
styled_df
species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | year | |
---|---|---|---|---|---|---|---|---|
0 | Adelie | Torgersen | 39.100000 | 18.700000 | 181.000000 | 3750.000000 | male | 2007 |
1 | Adelie | Torgersen | 39.500000 | 17.400000 | 186.000000 | 3800.000000 | female | 2007 |
2 | Adelie | Torgersen | 40.300000 | 18.000000 | 195.000000 | 3250.000000 | female | 2007 |
3 | Adelie | Torgersen | nan | nan | nan | nan | nan | 2007 |
4 | Adelie | Torgersen | 36.700000 | 19.300000 | 193.000000 | 3450.000000 | female | 2007 |
5 | Adelie | Torgersen | 39.300000 | 20.600000 | 190.000000 | 3650.000000 | male | 2007 |
6 | Adelie | Torgersen | 38.900000 | 17.800000 | 181.000000 | 3625.000000 | female | 2007 |
7 | Adelie | Torgersen | 39.200000 | 19.600000 | 195.000000 | 4675.000000 | male | 2007 |
8 | Adelie | Torgersen | 34.100000 | 18.100000 | 193.000000 | 3475.000000 | nan | 2007 |
9 | Adelie | Torgersen | 42.000000 | 20.200000 | 190.000000 | 4250.000000 | nan | 2007 |
10 | Adelie | Torgersen | 37.800000 | 17.100000 | 186.000000 | 3300.000000 | nan | 2007 |
Here we’ve made two additional reactive values (style
and color
), and written a Pandas pipeline reacting to those values, using precisely the same syntax you would with a regular Pandas expression. Since styled_df
is now a reactive Pandas expression, it will re-run whenever any of those changes. To see, try executing each of the following commands, one by one:
color.rx.value = 'red'
nrows.rx.value += 2
color.rx.value = 'darkblue'
In the code above, we made reactive strings, numbers, and DataFrame expressions. You can also make functions reactive, which lets you make the URL reactive as well:
url = rx(URL)
df = rx(pd.read_csv)(url)
df.head(2)
species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | year | |
---|---|---|---|---|---|---|---|---|
0 | Adelie | Torgersen | 39.1 | 18.7 | 181.0 | 3750.0 | male | 2007 |
1 | Adelie | Torgersen | 39.5 | 17.4 | 186.0 | 3800.0 | female | 2007 |
url.rx.value = 'https://datasets.holoviz.org/gapminders/v1/gapminders.csv'
url.rx.value = URL
In this case, df
wraps the read_csv
call generating the DataFrame, rather than a specific DataFrame instance. This demonstrates the flexibility of reactive expressions: you can write code as you usually would, but gain control over its reactivity.
While we’ve been updating .rx.value
manually in this notebook, you could easily replace these literals with widgets from ipywidgets or HoloViz Panel, enabling you to create user-interactive, reactive applications with minimal effort.
How it works#
So, how does reactive programming in Param actually work? The underlying mechanism leverage’s Python’s operator overloading, which allows us to redefine common operators like ‘+
’ to perform additional tasks. When you use these operators in reactive expressions, Param not only carries out the operation but also records it, establishing a dependency from the reactive variables involved to the resulting expression. This means that when a reactive variable is updated, Param automatically updates any expressions dependent on it.
For instance, if you set up a reactive expression j = i + 1
, where i
has been made reactive, any change to i
will automatically trigger an update in j
, eliminating the need for manual event handling:
i = rx(1)
j = i + 1
print(f'i = {i.rx.value}')
print(f'j = {j.rx.value}')
i = 1
j = 2
i.rx.value = 7
print(f'i = {i.rx.value}')
print(f'j = {j.rx.value}')
i = 7
j = 8
Without rx()
, adding 1 to i
would have immediately invoked integer addition in Python, assigning an integer 2 to j
. However, because we made i
a reactive expression, what happens is that i
stores its input value on an internal attribute called _obj
, while overloading +
to not just calculate i + 1
, but also return another reactive object that records the operation (i + 1
). This stores the dependency so that whenever i
changes, the reactive object knows that it needs to update itself by re-executing i + 1
.
type(i), i._obj, j._operation
(param.reactive.rx,
7,
{'fn': <function _operator.add(a, b, /)>,
'args': (1,),
'kwargs': {},
'reverse': False})
When you access the .value
attribute of j
, it retrieves the most recent result of the expression i + 1
, automatically reapplying the operation if i
has changed. For more complicated scenarios, reactive expressions can chain multiple operations and method calls together, executing them in sequence to obtain the final outcome.
In essence, reactive expressions are specialized Python objects that wrap standard objects. They record the operations you apply, and when an underlying reactive value changes, they automatically re-execute these operations. This eliminates the need for manually tracking and updating dependent variables, making it easier to build dynamic, responsive applications.
Limitations#
So does Python really allow all operations to be overloaded so that a reactive expression works precisely like the underlying objects?
Nearly, but not quite. For technical reasons, certain operations cannot be implemented in this way:
Python requires the
len
operation to return an integer, not a deferred reactive integerThe Python
is
statement always checks the immediate identity of its two operands, so it cannot be deferred reactivelyLogical operators like
and
,or
,not
, andin
are required to return Boolean types rather than deferred, reactive Boolean typesNo overloading is available for control flow keywords like
if
,elif
, andelse
or ternary conditional expressions (i.e.a if condition else b
), and so those actions cannot be captured for later reactive executionIteration keywords like
for
orwhile
can only be overloaded to some extent, specifically for fixed-length collections; other types of iteration cannot be captured for later reactive execution
However, Param’s reactive expressions offer workarounds for these limitations through special methods under the .rx
namespace to avoid confusion with the underlying object’s own methods. We’ll cover these methods in the next section.
Special Methods on .rx
#
To circumvent the limitations explained above, the .rx
namespace provides reactive versions of the operations that can’t be made reactive through overloading:
.rx.and_
: Reactive version ofand
..rx.bool
: Reactive version ofbool()
..rx.in_
: Reactive version ofin
, testing if the value is in the provided collection..rx.is_
: Reactive version ofis
, testing the object identity against another object..rx.is_not
: Reactive version ofis not
, testing the absence of object identity with another object..rx.len
: Reactive version oflen()
, returning the length of the expression.rx.map
: Applies a function to each item in a collection..rx.not_
: Reactive version ofnot
..rx.or_
: Reactive version ofor
..rx.pipe
: Applies the given function (with static or reactive arguments) to this object..rx.updating
: Returns a boolean indicating whether the expression is currently updating..rx.when
: Generates a new expression that only updates when the provided dependency updates..rx.where
: Returns either the first or the second argument, depending on the current value of the expression.
Unlike their corresponding standard Python equivalent, each of these returns a reactive expression that can thus be combined with other reactive expressions to make reactive pipelines.
.rx.and_(arg)
#
Applies the and
operator to the output of the reactive expression and the argument returning a new expression:
rx(True).rx.and_(False)
False
Unlike the bitwise and
operator (&
) this has the same semantics as the and
keyword.
.rx.in_(arg)
#
Reactively checks if the current value is .in_
the other collection
rx(2).rx.in_([1, 2, 3])
True
.rx.is_(arg)
#
Reactively checks if the identity of the current value is the same as the argument to .is_
rx(None).rx.is_(None)
True
.rx.is_not(arg)
#
Reactively checks if the identity of the current value is not the same as the argument to .is_not
rx(None).rx.is_not(None)
False
.rx.len()
#
Returns the length of the object as a reactive expression
obj = rx([1, 2, 3])
obj.rx.len()
3
.rx.map(func, *args, **kwargs)
#
Maps the function to each item in a collection returned by the expression:
rx([1, 2, 3]).rx.map(lambda v, mul: v*mul, mul=2)
[2, 4, 6]
.rx.or_(arg)
#
Applies or
to the output of the reactive expression and the argument:
rx(False).rx.or_('A value')
'A value'
Unlike the bitwise or
operator (|
) this has the same semantics as the or
keyword.
.rx.pipe(func, *args, **kwargs)
#
Pipes the current value into a function as the first argument, passing in additional positional and keyword arguments if provided, and returning a reactive expression to replay that call as needed:
def f(a, b): return a + b
rx(1).rx.pipe(f, 2)
3
.rx.pipe
can be used with any Python function. One common usage is for making type conversion functions reactive:
rx(8.5).rx.pipe(int)
8
rx(8.5).rx.pipe(str)
'8.5'
.rx.updating()
#
Returns a new expression that is True while the original expression is updating. Useful for performing some action while an expression is running.
Here we create a simple expression that calls a calculate
function which emulates a long running computation:
expr = rx(1)
def calculate(value):
time.sleep(1)
return value
updating = expr.rx.pipe(calculate).rx.updating()
updating
False
When we update the expression the updating
expression will temporarily toggle to True and then reset:
expr.rx.value += 1
.rx.when(*conditions)
#
Useful when creating UIs to declare that the expression should only update when some other parameter changes, e.g. when a user clicks a button or triggers an expensive operation through some other mechanism.
For instance, let’s say we have some expensive function (here simulated using time.sleep
). First, we bind parameters a
and b
to this function and create a reactive expression from this function.
import time
def expensive_function(a, b):
print(f'multiplying {a=} and {b=}')
time.sleep(2)
return a * b
a = rx(1)
b = rx(2)
expensive_expr = rx(expensive_function)(a, b)
The problem we face is that if we use this expensive_expr
whenever a
or b
are changed, then the expensive computation gets triggered twice if we want to change both a
and b
. To avoid unnecessary expense, we can gate the computation behind a third variable we’ll name gate
:
gate = rx(False)
gated_expr = expensive_expr.rx.when(gate)
gated_expr
multiplying a=1 and b=2
8
We can now safely change variables a
and b
separately without triggering the computation:
a.rx.value = 2
b.rx.value = 4
gated_expr.rx.value
2
But when we trigger the run
parameter the expression will re-compute:
gate.rx.value = True
gated_expr.rx.value
multiplying a=2 and b=4
8
.rx.where(a, b)
#
Reactive ternary conditional. In non-reactive Python code you can write:
a if condition else b
to return value a
or value b
depending on some condition
. However, Python does not allow overriding if
to have special behavior for a reactive condition
, and thus such an expression will immediately evaluate and return a
or b
rather than capturing this logic for later reactivity.
So if we want to have a reactive conditional, we have to rewrite the expression using where
. First, we will declare a reactive condition
expression to wrap a Boolean value that we can change later:
condition = rx(True)
Now let’s say we want to return either a
or b
depending on whether the condition
is True or False. We can simply pass the values to .where()
:
a = rx(1)
b = rx(2)
ternary_expr = condition.rx.where(a, b)
ternary_expr
5
Since the initial value is True
it returns the current value of a
, which is 1
. However when we set the value to False
it will return the value of b
:
condition.rx.value = False
ternary_expr.rx.value
2
Importantly, if we now change b
the result will be reflected by the expression, reactively unless we explicitly resolve the result:
b.rx.value = 5
ternary_expr.rx.value
5
Here the expression value depends only on b
thanks to the where
condition, and thus changes to a
will no longer trigger any downstream updates until the condition is reversed again.
Watching an expression#
In some cases you may want to trigger some side-effect based on the return value of an expression. The simplest way to achieve this is using the .rx.watch
API, which mirrors the .param.watch
API. Using this API we can define a callback which runs whenever the expression outputs a new event:
c = rx(1)
c.rx.watch(lambda v: print(f'Output: {v}'))
c
2
Now if we update the expression we will see the output run:
c.rx.value += 1
Output: 2
It is also possible to call .param.watch
without any arguments, which makes the expression evaluate eagerly.
Parameters and param.bind
#
Reactive expressions are part of the Param library, and behind the scenes, all the reactivity is implemented using Parameters and their dependencies and watchers support. You can use reactive expressions without needing to learn about Parameters, but if you do use Parameters in your work, they interact seamlessly with reactive expressions, providing a powerful and convenient way to organize your code and your work. In this section we will show how to use Parameters and the param.bind
function together with reactive expressions for a more structured approach to reactive programming.
First, let’s create a Parameterized class with a couple of Parameters:
class Parameters(param.Parameterized):
a = param.Number(1)
b = param.Number(0)
run = param.Event()
p = Parameters()
Any of the parameters can be used as reactive expressions by calling .rx()
on their Parameter object:
expr = p.param.a.rx() + p.param.b.rx() + 3
expr
13
Now if we update the Parameter, the result of the expression will update immediately:
p.b = 5
print(expr.rx.value)
9
You can thus use any Parameter in your reactive expressions, including Parameters from HoloViz Panel widgets. In fact, reactive expressions are natively supported in Panel.
Binding Parameters to Functions#
While reactive expressions with rx
offer a flexible way to define dynamic relationships between Parameters, they operate at a fairly abstract level, encapsulating the underlying transformations. This can sometimes make it challenging to isolate specific parts of a pipeline for debugging or performance optimization.
Enter param.bind
, which allows you to define functions that are automatically invoked when their input Parameters change. This serves as a bridge between the reactive rx
model and the lower-level ‘push’ model. Unlike the ‘push’ model, where you would explicitly set up watchers and callbacks, param.bind
simplifies the process by letting Param manage the mechanics, but also making the dependencies more transparent than in a purely rx
approach.
In essence, param.bind
offers the declarative nature of reactive expressions and the explicitness of the ‘push’ model. This makes it particularly useful for complex applications where you might want the clarity of explicit function calls for key parts of your pipeline, but also wish to retain the high-level, declarative relationships offered by reactive expressions.
To demonstrate this concept, let’s define a simple Python function for adding numbers. We’ll also include print statements to make it evident when the function is invoked:
def add(a, b):
print(f'add: {a}+{b}={a+b}')
return a + b
add(3, 7)
add: 3+7=10
10
Now we can use param.bind
to “bind” parameters a
and b
to the add
function’s arguments to create a reactive function:
reactive_add = param.bind(add, p.param.a, p.param.b)
reactive_add
add: 1+5=6
10
As you can see, reactive_add
works just like add
, in that it adds two arguments, but in this case, it’s taking the value of the a
and b
Parameters of p
. Parameter a
has been “bound” to the first argument and b
to the second, and if either of them changes, the result changes. So if we change p.a
to 5, the output above reacts immediately.
p.a += 4
add: 5+5=10
We can also call the reactive function explicitly to return the current result as a concrete, no longer reactive value:
reactive_add()
add: 5+5=10
10
The difference between reactive_add
and reactive_add()
is that the first one is a function, whose display will automatically update in IPython/Jupyter, while the second is a specific number (the result of calling that function a single time, never to be updated further):
print(type(reactive_add), type(reactive_add()))
add: 5+5=10
<class 'function'> <class 'int'>
param.bind
follows the semantics of Python’s functools.partial
, and so if you only partially bind the required arguments, you’ll get a function of the remaining arguments:
add_b = param.bind(add, p.param.a)
add_b
<function param.reactive.bind.<locals>.wrapped(*wargs, **wkwargs)>
add_b(5)
add: 5+5=10
10
Note that you can bind any accepted type to make a reactive function, not just Parameters, but static values won’t trigger reactive updates (here 38 will always be the same value, while the result will depend on the current value of p.param.a
).
param.bind(add, p.param.a, b=38)
add: 5+38=43
43
Bound functions update their outputs reactively when displayed, but what if you want to use one in a reactive expression? You can easily do that if you call .rx()
on a fully bound function to get a reactive expression to work with:
param.bind(add, p.param.a, p.param.b).rx() / 2
add: 5+5=10
5.0
As you can see, you can use bound functions to get reactivity if you prefer to write specific functions, or you can use reactive expressions to capture computations without writing function definitions, or you can combine the two as needed. Feel free to use the approach that best meets your needs!
And overall, hopefully, you can see that Param’s reactive support provides a natural and powerful way to capture your computations in a way that can be replayed automatically whenever inputs change, making it a convenient basis for building interactive applications and computations.