Dynamic UI

Updating inputs

examples/action-dynamic/slider-min-max/app.py
from shiny import App, ui, reactive

app_ui = ui.page_fluid(
    ui.input_numeric("min", "Minimum", 0),
    ui.input_numeric("max", "Maximum", 3),
    ui.input_slider("n", "n", min=0, max=3, value=1),
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.min)
    def _():
        ui.update_slider("n", min=input.min())
    
    @reactive.effect
    @reactive.event(input.max)
    def _():
        ui.update_slider("n", max=input.max())

app = App(app_ui, server)

Simple uses

examples/action-dynamic/reset/app.py
from shiny import App, ui, reactive

app_ui = ui.page_fluid(
    ui.input_slider("x1", "x1", min=-10, max=10, value=0),
    ui.input_slider("x2", "x2", min=-10, max=10, value=0),
    ui.input_slider("x3", "x3", min=-10, max=10, value=0),
    ui.input_action_button("reset", "Reset"),
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.reset)
    def _():
        ui.update_slider("x1", value=0)
        ui.update_slider("x2", value=0)
        ui.update_slider("x3", value=0)

app = App(app_ui, server)
examples/action-dynamic/action-button-label/app.py
from shiny import App, ui, reactive

app_ui = ui.page_fluid(
    ui.input_numeric("n", "Simulations", 10),
    ui.input_action_button("simulate", "Simulate"),
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.n)
    def _():
        label = f"Simulate {input.n()} times"
        ui.update_action_button("simulate", label=label)

app = App(app_ui, server)

Hierarchical select boxes

examples/action-dynamic/hierarchical-select-box/app.py
from shiny import App, ui, reactive, req, render
import pandas as pd

sales = pd.read_csv("sales-dashboard/sales_data_sample.csv",
                    sep=",", encoding="Latin-1", 
                    na_values=["", "NaN"], keep_default_na=False)

app_ui = ui.page_fluid(
    ui.input_select("territory", "Territory", choices=sales['TERRITORY'].unique().tolist()),
    ui.input_select("customername", "Customer", choices=list()),
    ui.input_select("ordernumber", "Order number", choices=list()),
    ui.output_table("data"),
)

def server(input, output, session):
    @reactive.calc
    def territory():
        return sales[sales['TERRITORY']==input.territory()]
    
    @reactive.effect
    @reactive.event(territory)
    def _():
        choices = territory()['CUSTOMERNAME'].unique().tolist()
        ui.update_select("customername", choices=choices)

    @reactive.calc
    def customer():
        req(input.customername())
        return territory()[territory()['CUSTOMERNAME']==input.customername()]
    
    @reactive.effect
    @reactive.event(customer)
    def _():
        choices = customer()['ORDERNUMBER'].unique().tolist()
        ui.update_select("ordernumber", choices=choices)
    
    @render.table
    def data():
        req(input.ordernumber())
        res = customer()[customer()['ORDERNUMBER']==int(input.ordernumber())]\
            [['QUANTITYORDERED', 'PRICEEACH', 'PRODUCTCODE']]
        
        return res

app = App(app_ui, server)
Note

Please note that choices=list() was used in ui.input_select() when creating empty drop-down list for select input control. choices=None does not work, because a value of choices must be list, tuple, or dictionary.

Note

Please note that input.ordernumber() returns string, even though underlying data variable ORDERNUMBER is integer. To compare input.ordernumber() with the underlying data variable, I did typecasting int(input.ordernumber()).

Freezing reactive inputs

examples/action-dynamic/freezing-reactive-input/app.py
from shiny import App, ui, reactive, render, req
from pydataset import data

app_ui = ui.page_fluid(
    ui.input_select("dataset", "Choose a dataset", choices=("pressure", "cars")),
    ui.input_select("column", "Choose column", choices=tuple()),
    ui.output_text_verbatim("summary"),
)

def server(input, output, session):
    @reactive.calc
    def dataset():
        return data(input.dataset())
    
    @reactive.effect
    @reactive.event(input.dataset)
    def _():
        reactive.value.freeze(input.column)
        ui.update_select("column", choices=dataset().columns.tolist())
    
    @render.text
    def summary():
        return dataset()[input.column()].describe()

app = App(app_ui, server)
Note

Please note that the argument of reactive.value.freeze() is input.column, not input.column().

Exercise

  1. Let date input allow a user to select only dates in the selected year.
solutions/action-dynamic/updating-inputs/update-year/app.py
from shiny import App, ui, reactive
import datetime

app_ui = ui.page_fluid(
    ui.input_numeric("year", "year", value=2020),
    ui.input_date("date", "date", value=datetime.date(2020, 1, 1)),
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.year)
    def _():
        d = datetime.date(
            input.year(),
            input.date().month,
            28 if input.date().month == 2 and input.date().day == 29 else input.date().day
        )
        ui.update_date("date", 
                       value=d,
                       min=datetime.date(input.year(), 1, 1),
                       max=datetime.date(input.year(), 12, 31))

app = App(app_ui, server)
  1. Hierarchical select boxes for state and county
solutions/action-dynamic/updating-inputs/update-county/app.py
# Please download county.csv from https://www.openintro.org/data/
# and store it to data/ folder
# before running this app

from shiny import App, ui, reactive
import pandas as pd

df = pd.read_csv("data/county.csv")
states = df['state'].unique().tolist()

app_ui = ui.page_fluid(
    ui.input_select("state", "State", choices=states),
    ui.input_select("county", "County", choices=list()),
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.state)
    def _():
        county_label = "County"
        if input.state()=="Louisiana":
            county_label = "Parish"
        elif input.state()=="Alaska":
            county_label = "Borough"

        counties = df[df['state']==input.state()]['name'].to_list()
        ui.update_select("county", label=county_label, choices=counties)


app = App(app_ui, server)

3 & 4.

solutions/action-dynamic/updating-inputs/gapminder/app.py
from shiny import App, ui, reactive, render
from gapminder import gapminder

continents = gapminder['continent'].unique().tolist()

app_ui = ui.page_fluid(
    ui.input_select("continent", "Continent", choices=["(All)"] + continents),
    ui.input_select("country", "Country", choices=list()),
    ui.output_table("data"),
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.continent)
    def _():
        if input.continent() == "(All)":
            countries = gapminder['country'].unique().tolist()
        else:
            countries = gapminder[gapminder['continent']==input.continent()]['country'].unique().tolist()
        ui.update_select("country", choices=countries)

    @reactive.calc
    def country():
        return gapminder[gapminder['country']==input.country()]
    
    @render.table
    def data():
        return country()

app = App(app_ui, server)

Dynamic visibility

examples/action-dynamic/navset/app.py
from shiny import App, ui, reactive

app_ui = ui.page_fluid(
    ui.layout_sidebar(
        ui.sidebar(
            ui.input_select("controller", "Show", choices=[f"panel{x}" for x in range(1, 4)])
        ),
        ui.navset_hidden(
            ui.nav_panel("panel1", "Panel 1 content"),
            ui.nav_panel("panel2", "Panel 2 content"),
            ui.nav_panel("panel3", "Panel 3 content"),
            id="switcher",
        )
    )
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.controller)
    def _():
        ui.update_navs("switcher", selected=input.controller())

app = App(app_ui, server)

Conditional UI

examples/action-dynamic/conditional-ui/app.py
from shiny import App, ui, reactive, render
import numpy as np
import matplotlib.pyplot as plt

parameter_tabs = ui.navset_hidden(
    ui.nav_panel("normal",
                 ui.input_numeric("mean", "mean", value=1),
                 ui.input_numeric("sd", "standard deviation", min=0, value=1),
    ),
    ui.nav_panel("uniform",
                 ui.input_numeric("min", "min", value=0),
                 ui.input_numeric("max", "max", value=1),
    ),
    ui.nav_panel("exponential",
                 ui.input_numeric("rate", "rate", value=1, min=0)
    ),
    id="params"
)

app_ui = ui.page_fluid(
    ui.layout_sidebar(
        ui.sidebar(
            ui.input_select("dist", "Distribution", 
                            choices=["normal", "uniform", "exponential"]
            ),
            ui.input_numeric("n", "Number of samples", value=100),
            parameter_tabs,
        ),
        ui.output_plot("hist"),
    )
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.dist)
    def _():
        ui.update_navs("params", selected=input.dist())
    
    @reactive.calc
    def sample():
        match input.dist():
            case "normal":
                res = np.random.normal(input.mean(), input.sd(), input.n())
            case "uniform":
                res = np.random.uniform(input.min(), input.max(), input.n())
            case "exponential":
                res = np.random.exponential(1 / input.rate(), input.n())
            case _:
                res = None
        
        return res
    
    @render.plot
    def hist():
        plt.hist(sample())

app = App(app_ui, server)

Wizard interface

examples/action-dynamic/wizard-interface/app.py
from shiny import App, ui, reactive

app_ui = ui.page_fluid(
    ui.navset_hidden(
        ui.nav_panel("page_1",
                     "Welcome!",
                     ui.input_action_button("page_12", "next")
        ),
        ui.nav_panel("page_2",
                     "Only one page to go",
                     ui.input_action_button("page_21", "prev"),
                     ui.input_action_button("page_23", "next")
        ),
        ui.nav_panel("page_3",
                     "You're done!",
                     ui.input_action_button("page_32", "prev")
        ),
        id="wizard"
    )
)

def server(input, output, session):
    def switch_page(i):
        ui.update_navs("wizard", selected=f"page_{i}")
    
    @reactive.effect
    @reactive.event(input.page_12)
    def _():
        switch_page(2)

    @reactive.effect
    @reactive.event(input.page_21)
    def _():
        switch_page(1)

    @reactive.effect
    @reactive.event(input.page_23)
    def _():
        switch_page(3)

    @reactive.effect
    @reactive.event(input.page_32)
    def _():
        switch_page(2)

app = App(app_ui, server)

Exercises

  1. Show additional control only if the user checks an “advanced” checkbox.
solutions/action-dynamic/dynamic-visibility/checkbox-for-control/app.py
from shiny import App, ui, reactive

app_ui = ui.page_fluid(
    ui.input_checkbox("advanced", "Advanced"),
    ui.navset_hidden(
        ui.nav_panel("empty"),
        ui.nav_panel("additional",
                     ui.input_numeric("number", "Additional control", 
                                      value=1, min=0, max=10)
        ),
        id="wizard"
    )
)

def server(input, output, session):
    @reactive.effect
    def _():
        ui.update_navs("wizard", 
                       selected="additional" if input.advanced() else "empty")


app = App(app_ui, server)
  1. Allow the user to choose which geom to use.
solutions/action-dynamic/diamonds/app.py
from shiny import App, ui, reactive, render
from pydataset import data
from plotnine import ggplot, aes, geom_histogram, geom_freqpoly, geom_density

diamonds = data("diamonds")

app_ui = ui.page_fluid(
    ui.input_select("geom", "geom function",
        choices=["geom_histogram", "geom_freqpoly", "geom_density"]
    ),
    ui.navset_hidden(
        ui.nav_panel("param1",
            ui.input_numeric("binwidth", "binwidth", value=0.1, step=0.1),
        ),
        ui.nav_panel("param2",
            ui.input_select("bw", "bw", 
                choices=["nrd0", "normal_reference", "scott", "silverman"],
                selected="nrd0"
            ),
        ),
        id="params",
    ),
    ui.output_plot("plot"),
)

def server(input, output, session):
    @reactive.effect
    @reactive.event(input.geom)
    def _():
        match input.geom():
            case "geom_histogram":
                panel = "param1"
            case "geom_freqpoly":
                panel = "param1"
            case "geom_density":
                panel = "param2"

        ui.update_navs("params", selected=panel)

    @render.plot
    def plot():
        plot = ggplot(diamonds, aes('carat'))
        if input.geom() == "geom_histogram":
            res = plot + geom_histogram(binwidth=input.binwidth())
        elif input.geom() == "geom_freqpoly":
            res = plot + geom_freqpoly(binwidth=input.binwidth())
        elif input.geom() == "geom_density":
            res = plot + geom_density(bw=input.bw())

        return res

app = App(app_ui, server)

Creating UI with code

Getting started

examples/action-dynamic/creating-ui/app.py
from shiny import App, ui, reactive, render, req

app_ui = ui.page_fluid(
    ui.input_text("label", "label"),
    ui.input_select("type", "type", choices=("slider", "numeric")),
    ui.output_ui("numeric"),
)

def server(input, output, session):
    @render.ui
    def numeric():
        with reactive.isolate():
            try:
                value = input.dynamic()
            except:
                value = 0

        if input.type() == "slider":
            res = ui.input_slider("dynamic", input.label(), value=value, min=0, max=10)
        else:
            res = ui.input_numeric("dynamic", input.label(), value=value, min=0, max=10)

        return res

app = App(app_ui, server)
Note

Simply including the following code did not create UI.

with reactive.isolate():
    value = input.dynamic()

I guess it is because input.dynamic() sliently throw an error and does not proceed the remaining tasks in the render function. After including explicit error handling with try and except, it performed as desired.

Multiple controls

examples/action-dynamic/color-palette/app.py
from shiny import App, ui, reactive, render
import matplotlib.pyplot as plt
from matplotlib.colors import is_color_like

app_ui = ui.page_fluid(
    ui.layout_sidebar(
        ui.sidebar(
            ui.input_numeric("n", "Number of colours", value=5, min=1),
            ui.output_ui("col"),
        ),
        ui.output_plot("plot"),
    )
)

def server(input, output, session):
    @reactive.calc
    def col_names():
        return [f"col{x}" for x in range(1, input.n() + 1)]
    
    @render.ui
    def col():
        with reactive.isolate():
            values = {}
            for x in col_names():
                try:
                    values[x] = input[x]()
                except:
                    values[x] = ""

        return [ui.input_text(x, None, value=values[x]) for x in col_names()]
 
    @reactive.calc
    def palette():
        return [input[x]() for x in col_names()]
    
    @render.plot
    def plot():
        cols = [x if is_color_like(x) else "none" for x in palette()]

        fig, ax = plt.subplots()

        ax.bar(x=col_names(), height=1, width=1, color=cols, edgecolor='grey')
        ax.set_axis_off()

        return ax

    
app = App(app_ui, server)

Dynamic filtering

examples/action-dynamic/dynamic-filtering/app.py
from shiny import App, ui, reactive, render
import numpy as np
import pandas as pd
from pandas.api.types import is_numeric_dtype, is_string_dtype
from pydataset import data

def make_ui(x, var):
    if is_numeric_dtype(x):
        rng = (x.min(), x.max())
        res = ui.input_slider(var, var, min=rng[0], max=rng[1], value=rng)
    elif is_string_dtype(x):
        levs = list(np.unique(x))
        res = ui.input_select(var, var, choices=levs, selected=levs, multiple=True)
    else:
        res = None

    return res

def filter_var(x, val):
    if is_numeric_dtype(x):
        res = (~np.isnan(x)) & (x >= val[0]) & (x <= val[1])
    elif is_string_dtype(x):
        res = np.isin(x, val)
    else:
        res = True
    
    return res

iris = data('iris')

app_ui = ui.page_fluid(
    ui.layout_sidebar(
        ui.sidebar(
            make_ui(iris['Sepal.Length'], "Sepal_Length"),
            make_ui(iris['Sepal.Width'], "Sepal_Width"),
            make_ui(iris['Species'], "Species"),
        ),
        ui.output_table("data"),
    )
)

def server(input, output, session):
    @reactive.calc
    def selected():
        res = filter_var(iris['Sepal.Length'], input['Sepal_Length']()) & \
            filter_var(iris['Sepal.Width'], input['Sepal_Width']()) &\
            filter_var(iris['Species'], input['Species']())
        
        return res
    
    @render.table
    def data():
        return iris[selected()].head(12)

app = App(app_ui, server)
Note

Shiny for Python does not allow period(.) be a part of input ID. Therefore, I replaced . with _ when creating input control while let column names still have a period.

This makes it difficult to create more generalized app that filter any (even user-uploaded) data with any column name type, as shown in Mastering Shiny book for R.

More generalized data is as follows:

examples/action-dynamic/dynamic-filtering/app_generalized.py
from shiny import App, ui, reactive, render
import numpy as np
import pandas as pd
from pandas.api.types import is_numeric_dtype, is_string_dtype
from pydataset import data
import janitor
from functools import reduce

dfs = data()['dataset_id'].to_list()

def make_ui(x, var):
    if is_numeric_dtype(x):
        rng = (x.min(), x.max())
        res = ui.input_slider(var, var, min=rng[0], max=rng[1], value=rng)
    elif is_string_dtype(x):
        levs = list(np.unique(x))
        res = ui.input_select(var, var, choices=levs, selected=levs, multiple=True)
    else:
        res = None

    return res

def filter_var(x, val):
    if is_numeric_dtype(x):
        res = (~np.isnan(x)) & (x >= val[0]) & (x <= val[1])
    elif is_string_dtype(x):
        res = np.isin(x, val)
    else:
        res = True
    
    return res

app_ui = ui.page_fluid(
    ui.layout_sidebar(
        ui.sidebar(
            ui.input_select("_dataset", label="Dataset", choices=dfs),
            ui.output_ui("_filter"),
        ),
        ui.output_table("_data"),
    )
)

def server(input, output, session):
    @reactive.calc
    def cleaned_data():
        return data(input._dataset()).clean_names(case_type='snake')

    @reactive.calc
    def _vars():
        return cleaned_data().columns
    
    @render.ui
    def _filter():
        return list(map(lambda x: make_ui(cleaned_data()[x], x), _vars())),

    @reactive.calc
    def selected():
        each_var = map(lambda x: filter_var(cleaned_data()[x], input[x]()), _vars())
        res = reduce(lambda x, y: x & y, each_var)
        
        return res
    
    @render.table
    def _data():
        return cleaned_data()[selected()].head(12)

app = App(app_ui, server)
Note

I cleaned column names by using {pyjanitor} package before dynamically creating UIs. This was to automate UI creation based on column names.

Also, I placed a leading underscore(_) for static UI’s ID, to reduce a chance that dynamic UI’s ID conflict with static UI’s ID.

Note

I used is_string_dtype() to check whether the column is for categorical variable because data columns have not been converted to categorical variable yet. It would be better to consider converting columns to Categorical dtype first and use a function is_categorical_dtype() or its equivalent.

Excercises

solutions/action-dynamic/creating-ui/keep-values-in-sync/app.py
from shiny import App, reactive, render, req, ui

app_ui = ui.page_fluid(
    ui.input_select("type", "type", ["slider", "numeric"]),
    ui.navset_hidden(
        ui.nav_panel("slider",
                     ui.input_slider("n_slider", "n", value=0, min=0, max=100)),
        ui.nav_panel("numeric",
                     ui.input_numeric("n_numeric", "n", value=0, min=0, max=100)),
        id="wizard",
    ),
)


def server(input, output, session):
    @reactive.effect
    def _():
        ui.update_navs("wizard", selected=input.type())
    
    @reactive.effect
    @reactive.event(input.n_slider)
    def _():
        ui.update_numeric("n_numeric", value=input.n_slider())

    @reactive.effect
    @reactive.event(input.n_numeric)
    def _():
        ui.update_slider("n_slider", value=input.n_numeric())
    

app = App(app_ui, server)
Warning

When switching from numeric input control to slider input control, select value of slider input appears to be 0 for <1 second prior to automatically showing a synced value. I hope to find a solution to avoid this issue.

solutions/action-dynamic/creating-ui/passwrd-dialog/app.py
from shiny import App, reactive, render, req, ui

app_ui = ui.page_fluid(
    ui.input_action_button("go", "Enter password"),
    ui.output_text("text"),
)

def server(input, output, session):
    pw = reactive.value('')

    @reactive.effect
    @reactive.event(input.go)
    def _():
        m = ui.modal(
            ui.input_password("password", "Password: ", value=pw.get()),
            ui.input_action_button("submit", "Submit"),
            title="Please enter your password",
            footer=None,
        )
        ui.modal_show(m)

    @reactive.calc
    @reactive.event(input.submit)
    def get_password():
        res = input.password()
        pw.set(res)
        ui.modal_remove()
        return res

    @render.text
    def text():
        if get_password()=="":
            return "No password"
        
        return "Password entered"


app = App(app_ui, server)
solutions/action-dynamic/creating-ui/dynamic-filtering-date/app.py
from shiny import App, ui, reactive, render
import numpy as np
import pandas as pd
from pandas.api.types import is_numeric_dtype, is_string_dtype, is_datetime64_dtype
import janitor
from functools import reduce
from datetime import datetime

data = pd.DataFrame({
    'date': pd.to_datetime(['2021-01-01', '2021-01-02', '2021-01-03']).to_pydatetime(),
    'datetime': pd.to_datetime(['2021-01-01 00:00:00', '2021-01-02 01:00:00', '2021-01-03 23:59:59'])
})

def make_ui(x, var):
    if is_numeric_dtype(x):
        rng = (x.min(), x.max())
        res = ui.input_slider(var, var, min=rng[0], max=rng[1], value=rng)
    elif is_string_dtype(x):
        levs = list(np.unique(x))
        res = ui.input_select(var, var, choices=levs, selected=levs, multiple=True)
    elif is_datetime64_dtype(x):
        rng = (x.min().to_pydatetime().date(), x.max().to_pydatetime().date())
        res = ui.input_date_range(var, var, min=rng[0], max=rng[1], start=rng[0], end=rng[1])
    else:
        res = None

    return res

def filter_var(x, val):
    if is_numeric_dtype(x):
        res = (~np.isnan(x)) & (x >= val[0]) & (x <= val[1])
    elif is_string_dtype(x):
        res = np.isin(x, val)
    elif is_datetime64_dtype(x):
        res = (~np.isnan(x)) & \
            (x >= datetime.combine(val[0], datetime.min.time())) & \
            (x <= datetime.combine(val[1], datetime.max.time()))
    else:
        res = True
    
    return res

app_ui = ui.page_fluid(
    ui.layout_sidebar(
        ui.sidebar(
            ui.output_ui("_filter"),
        ),
        ui.output_table("_data"),
    )
)

def server(input, output, session):
    @render.ui
    def _filter():
        return list(map(lambda x: make_ui(data[x], x), data.columns))

    @reactive.calc
    def selected():
        each_var = map(lambda x: filter_var(data[x], input[x]()), data.columns)
        res = reduce(lambda x, y: x & y, each_var)
        
        return res
    
    @render.table
    def _data():
        return data[selected()].head(12)

app = App(app_ui, server)