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:

  1. Reactive Expressions: With .rx, create reactive proxies for Parameters or objects, which recompute as inputs change.

  2. 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 integer

  • The Python is statement always checks the immediate identity of its two operands, so it cannot be deferred reactively

  • Logical operators like and, or, not, and in are required to return Boolean types rather than deferred, reactive Boolean types

  • No overloading is available for control flow keywords like if, elif, and else or ternary conditional expressions (i.e. a if condition else b), and so those actions cannot be captured for later reactive execution

  • Iteration keywords like for or while 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 of and.

  • .rx.bool: Reactive version of bool().

  • .rx.in_: Reactive version of in, testing if the value is in the provided collection.

  • .rx.is_: Reactive version of is, testing the object identity against another object.

  • .rx.is_not: Reactive version of is not, testing the absence of object identity with another object.

  • .rx.len: Reactive version of len(), returning the length of the expression

  • .rx.map: Applies a function to each item in a collection.

  • .rx.not_: Reactive version of not.

  • .rx.or_: Reactive version of or.

  • .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.