Interactivity#

In the previous sections, we delved into Param, which not only forms the core architecture of Panel but also serves as the foundation for adding interactivity to your applications. This section explores how to leverage Parameters and their dependencies to incorporate interactivity. We’ll focus on implementing interactivity through reactivity, departing from the more imperative style of programming commonly found in other UI frameworks.

By the end of this tutorial, you’ll learn:

  • How to utilize both declarative and imperative APIs for interactivity

  • How to develop both functional and class-based interactive apps

Imperative vs Declarative Programming#

To create an interactive component in Panel, we have two approaches: defining callbacks for explicit actions or declaring reactive functions, methods, or expressions that automatically manage state changes.

Tip

For users of all skill levels, we recommend employing the Declarative Programming approach as it enhances code maintainability and efficiency.

Let’s explore these approaches by building a simple app that enables us to select a subset of columns to display in a table.

Imperative#

We begin by loading our data and defining a widget for interacting with it:

import panel as pn
import pandas as pd

pn.extension("tabulator")

data_url = 'https://assets.holoviz.org/panel/tutorials/turbines.csv.gz'

turbines = pn.cache(pd.read_csv)(data_url)

cols = pn.widgets.MultiChoice(
    options=turbines.columns.to_list(), value=['p_name', 't_state', 't_county', 'p_year', 't_manu', 'p_cap'],
    width=500, height=100, name='Columns'
)

In the imperative approach, we use .param.watch to set up a callback that updates the data when the widget changes:

table = pn.widgets.Tabulator(turbines[cols.value], page_size=5, pagination="remote")

def update_data(event):
    table.value = turbines[event.new]

cols.param.watch(update_data, 'value')

pn.Column(cols, table).servable()

Declarative#

The declarative and reactive approach involves declaring what we want to display, leaving Panel to handle the mechanics of updating the table:

dfrx = pn.rx(turbines)[cols]

pn.Column(cols, pn.widgets.Tabulator(dfrx, page_size=5, pagination="remote")).servable()

Note how we pass the reactive DataFrame dfrx to the Tabulator widget. This aligns with the concept of passing references, which Param and Panel resolve. Valid references include:

  • param.Parameter()

  • param.rx(...)

  • pn.bind(...)/ @pn.depends

  • pn.widgets.Widget()

Exercise: Add more Widgets#

Enhance the app by adding widgets to filter the data by year (p_year) and capacity (p_cap):

Hint

You can filter a reactive DataFrame in the same way as a regular DataFrame.

Solution: Declarative (Recommended)
import pandas as pd
import panel as pn

pn.extension("tabulator")

data_url = "https://assets.holoviz.org/panel/tutorials/turbines.csv.gz"

turbines = pn.cache(pd.read_csv)(data_url)

cols = pn.widgets.MultiChoice(
    options=turbines.columns.to_list(),
    value=["p_name", "t_state", "t_county", "p_year", "t_manu", "p_cap"],
    width=500,
    height=100,
    name="Columns",
)
p_year_options = sorted(int(year) for year in turbines.p_year.unique() if not pd.isna(year))
p_year = pn.widgets.Select(value=max(p_year_options), options=p_year_options, name="Year")

p_cap_bounds = (turbines.p_cap.min(), turbines.p_cap.max())
p_cap = pn.widgets.RangeSlider(value=p_cap_bounds, start=p_cap_bounds[0], end=p_cap_bounds[1])

dfrx = pn.rx(turbines)
dfrx = dfrx[
    (dfrx.p_year == p_year)
    & (dfrx.p_cap.between(p_cap.param.value_start, p_cap.param.value_end))
][cols]
pn.Column(
    cols, p_year, p_cap, pn.widgets.Tabulator(dfrx, pagination="remote", page_size=5)
).servable()
Solution: Imperative (Not recommended)
import pandas as pd
import panel as pn

pn.extension("tabulator")

data_url = "https://assets.holoviz.org/panel/tutorials/turbines.csv.gz"

turbines = pn.cache(pd.read_csv)(data_url)

cols = pn.widgets.MultiChoice(
    options=turbines.columns.to_list(),
    value=["p_name", "t_state", "t_county", "p_year", "t_manu", "p_cap"],
    width=500,
    height=100,
    name="Columns",
)
p_year_options = sorted(
    int(year) for year in turbines.p_year.unique() if not pd.isna(year)
)
p_year = pn.widgets.Select(
    value=max(p_year_options), options=p_year_options, name="Year"
)

p_cap_bounds = (turbines.p_cap.min(), turbines.p_cap.max())
p_cap = pn.widgets.RangeSlider(
    value=p_cap_bounds, start=p_cap_bounds[0], end=p_cap_bounds[1], name="Capacity"
)

table = pn.widgets.Tabulator(turbines[cols.value], page_size=5, pagination="remote")


def update_data(event):
    value = turbines[
        (turbines.p_year == p_year.value)
        & (turbines.p_cap.between(p_cap.value_start, p_cap.value_end))
    ][cols.value]

    table.value = value


cols.param.watch(update_data, "value")
p_year.param.watch(update_data, "value")
p_cap.param.watch(update_data, "value")

pn.Column(cols, p_year, p_cap, table).servable()

Function vs. Class-based#

Reactive functions and expressions based on pn.rx or pn.bind provide an excellent entry point for writing dynamic UIs. However, when we need to track state or have many consumers of the output, it can be challenging to manage. This is where Parameterized classes come in handy.

If you recall the Reactive Parameters Section, a Parameterized class enables you to encapsulate state as parameters, which can then be passed around to set up interactivity.

Tip

The class-based approach is recommended for larger, more complex applications.

Making the Class-Based Approach Efficient#

Let’s revisit our DataExplorer class from the previous lesson and see how we can structure a filtering application like before:

import pandas as pd
import panel as pn
import param
from panel.viewable import Viewer

pn.extension("tabulator")

data_url = "https://assets.holoviz.org/panel/tutorials/turbines.csv.gz"

turbines = pn.cache(pd.read_csv)(data_url)


class DataExplorer(Viewer):
    data = param.DataFrame(doc="Stores a DataFrame to explore")

    columns = param.ListSelector(
        default=["p_name", "t_state", "t_county", "p_year", "t_manu", "p_cap"]
    )

    year = param.Range(default=(1981, 2022), bounds=(1981, 2022))

    capacity = param.Range(default=(0, 1100), bounds=(0, 1100))

    def __init__(self, **params):
        super().__init__(**params)
        self.param.columns.objects = self.data.columns.to_list()

    @param.depends("data", "columns", "year", "capacity")
    def filtered_data(self):
        df = self.data
        return df[df.p_year.between(*self.year) & df.p_cap.between(*self.capacity)][
            self.columns
        ]

    @param.depends('filtered_data')
    def number_of_rows(self):
        return f"Rows: {len(self.filtered_data())}"

    def __panel__(self):
        return pn.Column(
            pn.Row(
                pn.widgets.MultiChoice.from_param(self.param.columns, width=400),
                pn.Column(self.param.year, self.param.capacity),
            ),
            self.number_of_rows,
            pn.widgets.Tabulator(self.filtered_data, page_size=10, pagination="remote"),
        )


DataExplorer(data=turbines).servable()

As you can see, param.depends allows us to set up a method that depends on specific parameters of the class (much like pn.bind) and then use that method as a proxy for the filtered data. If you observe the execution flow, you’ll notice that the filtered_data method is executed twice whenever one of data, columns, year, or capacity is changed. You can avoid this inefficiency by using pn.cache.

An alternative and slightly more efficient approach would be to create a parameter to store the filtered data and update it every time one of the dependencies changes.

import pandas as pd
import panel as pn
import param
from panel.viewable import Viewer

pn.extension("tabulator")

data_url = "https://assets.holoviz.org/panel/tutorials/turbines.csv.gz"

turbines = pn.cache(pd.read_csv)(data_url)


class DataExplorer(Viewer):
    data = param.DataFrame(doc="Stores a DataFrame to explore")

    columns = param.ListSelector(
        default=["p_name", "t_state", "t_county", "p_year", "t_manu", "p_cap"]
    )

    year = param.Range(default=(1981, 2022), bounds=(1981, 2022))

    capacity = param.Range(default=(0, 1100), bounds=(0, 1100))

    filtered_data = param.DataFrame(doc="Stores the filtered DataFrame")

    def __init__(self, **params):
        super().__init__(**params)
        self.param.columns.objects = self.data.columns.to_list()

    @param.depends("data", "columns", "year", "capacity", watch=True, on_init=True)
    def _update_filtered_data(self):
        df = self.data
        self.filtered_data=df[df.p_year.between(*self.year) & df.p_cap.between(*self.capacity)][
            self.columns
        ]

    @param.depends('filtered_data')
    def number_of_rows(self):
        return f"Rows: {len(self.filtered_data)}"

    def __panel__(self):
        return pn.Column(
            pn.Row(
                pn.widgets.MultiChoice.from_param(self.param.columns, width=400),
                pn.Column(self.param.year, self.param.capacity),
            ),
            self.number_of_rows,
            pn.widgets.Tabulator(self.param.filtered_data, page_size=10, pagination="remote"),
        )

DataExplorer(data=turbines).servable()

Storing the filtered_data has the benefit that multiple consumers can now access it without recalculating it.

We can also combine the class-based approach with pn.rx to achieve an efficient solution:

import pandas as pd
import panel as pn
import param
from panel.viewable import Viewer

pn.extension("tabulator")

data_url = "https://assets.holoviz.org/panel/tutorials/turbines.csv.gz"

turbines = pn.cache(pd.read_csv)(data_url)


class DataExplorer(Viewer):
    data = param.DataFrame(doc="Stores a DataFrame to explore")

    columns = param.ListSelector(
        default=["p_name", "t_state", "t_county", "p_year", "t_manu", "p_cap"]
    )

    year = param.Range(default=(1981, 2022), bounds=(1981, 2022))

    capacity = param.Range(default=(0, 1100), bounds=(0, 1100))

    filtered_data = param.Parameter()

    number_of_rows = param.Parameter()

    def __init__(self, **params):
        super().__init__(**params)
        self.param.columns.objects = self.data.columns.to_list()

        dfrx = self.param.data.rx()

        p_year_min = self.param.year.rx().rx.pipe(lambda x: x[0])
        p_year_max = self.param.year.rx().rx.pipe(lambda x: x[1])
        p_cap_min = self.param.capacity.rx().rx.pipe(lambda x: x[0])
        p_cap_max = self.param.capacity.rx().rx.pipe(lambda x: x[1])

        self.filtered_data = dfrx[
            dfrx.p_year.between(p_year_min, p_year_max)
            & dfrx.p_cap.between(p_cap_min, p_cap_max)
        ][self.param.columns]

        self.number_of_rows = pn.rx("Rows: {len_df}").format(len_df=pn.rx(len)(dfrx))

    def __panel__(self):
        return pn.Column(
            pn.Row(
                pn.widgets.MultiChoice.from_param(self.param.columns, width=400),
                pn.Column(self.param.year, self.param.capacity),
            ),
            self.number_of_rows,
            pn.widgets.Tabulator(self.filtered_data, page_size=10, pagination="remote"),
        )


DataExplorer(data=turbines).servable()

Tip

  • If your dependent function (@pn.depends) will only be executed once per update, the first approach is recommended as it’s simple to implement and reason about.

  • If not, we recommend using the second approach (@pn.depends with watch=True) or the third approach (pn.rx) because it’s much easier to implement efficiently.

Exercise: Add a Plot#

Write an app that allows filtering the DataFrame and displays both a table and a plot, caching the data on an intermediate parameter. You can use any plotting library you want.

Hint

import hvplot.pandas
plot = turbines.hvplot.hist("p_cap", height=400)
Solution
import hvplot.pandas
import pandas as pd
import panel as pn
import param
from panel.viewable import Viewer

pn.extension("tabulator")

data_url = "https://assets.holoviz.org/panel/tutorials/turbines.csv.gz"

turbines = pn.cache(pd.read_csv)(data_url)


class DataExplorer(Viewer):
    data = param.DataFrame(doc="Stores a DataFrame to explore")

    columns = param.ListSelector(
        default=["p_name", "t_state", "t_county", "p_year", "t_manu", "p_cap"]
    )

    year = param.Range(default=(1981, 2022), bounds=(1981, 2022))

    capacity = param.Range(default=(0, 1100), bounds=(0, 1100))

    filtered_data = param.DataFrame(doc="Stores the filtered DataFrame")

    def __init__(self, **params):
        super().__init__(**params)
        self.param.columns.objects = self.data.columns.to_list()

    @param.depends("data", "year", "capacity", watch=True, on_init=True)
    def _update_filtered_data(self):
        df = self.data
        self.filtered_data = df[
            df.p_year.between(*self.year) & df.p_cap.between(*self.capacity)
        ]

    @param.depends("filtered_data", "columns")
    def table(self):
        return self.filtered_data[self.columns]

    @param.depends("filtered_data")
    def plot(self):
        return self.filtered_data.hvplot.hist("p_cap", height=400)

    def __panel__(self):
        return pn.Column(
            pn.Row(
                pn.widgets.MultiChoice.from_param(self.param.columns, width=400),
                pn.Column(self.param.year, self.param.capacity),
            ),
            pn.widgets.Tabulator(self.table, page_size=10, pagination="remote"),
            pn.pane.HoloViews(self.plot),
        )


DataExplorer(data=turbines).servable()

Recap#

In this tutorial, we explored how to build an efficient filtering application using Panel. The focus was on optimizing the class-based approach to handle filtering operations on a DataFrame efficiently.

Key Concepts Covered#

  1. Class-Based Approach:

    • We started by revisiting a class-based approach for building interactive apps in Panel. This involved creating a DataExplorer class to handle filtering operations on a DataFrame.

  2. Parameter Dependencies:

    • Utilizing param.depends, we established dependencies between different parameters and methods within the DataExplorer class. This allowed us to trigger updates to specific methods whenever the parameters changed.

  3. Imperative vs Declarative Programming:

    • We discussed two programming paradigms for building interactive components in Panel:

      • Imperative: Defining explicit callbacks to perform actions based on widget changes.

      • Declarative: Using reactive functions or expressions to automatically manage state updates based on input changes. We recommended the declarative approach for its maintainability and efficiency.

  4. Efficiency Considerations:

    • We explored the importance of efficiency when dealing with large datasets or complex filtering operations. Inefficient code can lead to unnecessary recalculations and decreased performance.

  5. Improving Efficiency:

    • To enhance efficiency, we explored three approaches:

      • Using pn.cache to cache the filtered data.

      • Storing the filtered data as a parameter and updating it when dependencies change.

      • Combining the class-based approach with pn.rx for reactive programming.

Key Takeaways#

  • Imperative vs Declarative Programming: Understanding the difference between imperative and declarative programming paradigms helps in choosing the most suitable approach for building interactive components in Panel.

  • Parameter Dependencies: Establishing dependencies between parameters and methods using param.depends is fundamental for reactive updates in Panel apps.

  • Efficiency is Crucial: When building interactive applications, especially with large datasets, prioritizing efficiency is essential to ensure optimal performance.

  • Caching and Storing Data: Techniques like caching filtered data and storing it as a parameter can significantly improve efficiency by minimizing redundant computations.

  • Flexibility with Panel: Panel provides flexibility in designing interactive web apps, allowing developers to integrate various visualization components seamlessly.

By applying these concepts and techniques, developers can create efficient and responsive filtering applications using Panel, tailored to specific data exploration needs.