Shiny Reactivity

Slide Contents


title: “Shiny Reactivity” execute: echo: false —

Further reading

What makes Shiny special?

Reactivity: outputs update automatically when their inputs change, with no callbacks.

The reactive graph

Shiny builds and maintains a dependency graph for your app:

Rectangle

Reactive input

Hexagon

@reactive.calc

Circle

Reactive output

Part 1: How Shiny evaluates your app

The app’s reactive graph

%%| fig-width: 4
flowchart TD
  Sp[Species] --> Fi{{filtered}}
  X[X Axis] --> Pl((plot))
  Y[Y Axis] --> Pl
  Fi --> Pl
  Fi --> Co((count))

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  linkStyle 0,1,2,3,4 display:none
  • Species: checkbox group
  • X Axis / Y Axis: dropdowns
  • filtered: @reactive.calc
  • plot: @render.plot
  • count: @render.text

Shiny is lazy: nothing runs until needed

%%| fig-width: 4
flowchart TD
  Sp[Species] --> Fi{{filtered}}
  X[X Axis] --> Pl((plot)):::active
  Y[Y Axis] --> Pl
  Fi --> Pl
  Fi --> Co((count)):::active

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  linkStyle 0,1,2,3,4 display:none
  • Both outputs need to render
  • Shiny runs them and tracks every reactive value they read

plot and count both read filtered

%%| fig-width: 4
flowchart TD
  Sp[Species] --> Fi{{filtered}}:::active
  X[X Axis] --> Pl((plot))
  Y[Y Axis] --> Pl
  Fi --> Pl
  Fi --> Co((count))

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  linkStyle 0,1,2 display:none
  • filtered() is called by both outputs
  • Shiny runs it once, caches the result, and records:
    • filtered → plot
    • filtered → count

filtered reads Species; plot reads X and Y

%%| fig-width: 4
flowchart TD
  Sp[Species]:::active --> Fi{{filtered}}
  X[X Axis]:::active --> Pl((plot))
  Y[Y Axis]:::active --> Pl
  Fi --> Pl
  Fi --> Co((count))

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  • All dependencies recorded, the full graph is built
  • Shiny now watches it: any change triggers only the affected branch

Species changes: invalidation cascades

%%| fig-width: 4
flowchart TD
  Sp[Species]:::active --> Fi{{filtered}}:::invalid
  X[X Axis] --> Pl((plot)):::invalid
  Y[Y Axis] --> Pl
  Fi --> Pl
  Fi --> Co((count)):::invalid

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  • Species changes: filtered cache discarded, plot and count marked stale
  • X Axis and Y Axis are untouched: only the affected branch re-runs
  • filtered() executes once, both outputs share the new result

Part 2: Sharing an intermediate dataframe

The problem without @reactive.calc

@render.plot
def plot():
    sel = dat[dat["species"].isin(input.species())]  # filter #1
    return ggplot(sel, aes(x=input.x(), y=input.y(), color="species")) + ...

@render.text
def count():
    sel = dat[dat["species"].isin(input.species())]  # filter #2, redundant!
    return f"{len(sel)} penguins selected"
  • Species changes: the filter runs twice
  • More outputs make this worse

The solution: @reactive.calc

from shiny import reactive

1@reactive.calc
def filtered():
    return dat[dat["species"].isin(input.species())]

@render.plot
2def plot():
    return ggplot(filtered(), aes(x=input.x(), y=input.y(), color="species")) + ...

@render.text
def count():
    return f"{len(filtered())} penguins selected"
1
@reactive.calc allows you to create filtered(): it runs once per change and caches the result
2
Both outputs call filtered(): they get the cached dataframe
Tip🎯 Live Demo

Run code/demos/app-03-reactivity.py.

Part 3: Chaining reactive calcs

Adding a second calc downstream

What if we also want a summary table of per-species statistics?

The summary depends on:

  • filtered(): which rows to include
  • input.y(): which column to summarise

We build a second calc downstream of the first, instead of re-filtering inside the table render.

The chained reactive graph

%%| fig-width: 4
flowchart TD
  Sp[Species] --> Fi{{filtered}}
  Y[Y Axis] --> Pl((plot))
  X[X Axis] --> Pl
  Y --> Su{{summary}}
  Fi --> Pl
  Fi --> Co((count))
  Fi --> Su
  Su --> Ta((table))

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  classDef cached fill:#86BAE4,color:#231522,font-weight:bold
  • filtered: species filter (as before)
  • summary: per-species stats for the selected Y column
  • table: @render.data_frame fed by summary

summary depends on both filtered and Y Axis.

Y Axis changes: surgical invalidation

%%| fig-width: 4
flowchart TD
  Sp[Species] --> Fi{{filtered}}:::cached
  Y[Y Axis]:::active --> Pl((plot)):::invalid
  X[X Axis] --> Pl
  Y --> Su{{summary}}:::invalid
  Fi --> Pl
  Fi --> Co((count)):::cached
  Fi --> Su
  Su --> Ta((table)):::invalid

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  classDef cached fill:#86BAE4,color:#231522,font-weight:bold
  • Y Axis changes: plot, summary, and table re-run
  • filtered and count are unaffected: Species didn’t change, so that branch stays cached
  • Shiny re-runs the minimum necessary

Species changes: cascade through both calcs

%%| fig-width: 4
flowchart TD
  Sp[Species]:::active --> Fi{{filtered}}:::invalid
  Y[Y Axis] --> Pl((plot)):::invalid
  X[X Axis] --> Pl
  Y --> Su{{summary}}:::invalid
  Fi --> Pl
  Fi --> Co((count)):::invalid
  Fi --> Su
  Su --> Ta((table)):::invalid

  classDef default fill:#F5F5F5,color:#231522,stroke:#B8B8B8
  classDef active fill:#3853A3,color:#FFFFFF,font-weight:bold
  classDef invalid fill:#EA1F26,color:#FFFFFF,font-weight:bold
  classDef cached fill:#86BAE4,color:#231522,font-weight:bold
  • Species changes: filtered cache discarded, everything downstream is stale
  • filtered() runs once, summary() runs once using the new filtered result
  • All four outputs update

The chained code

@reactive.calc
1def filtered():
    return dat[dat["species"].isin(input.species())]

@reactive.calc
2def summary():
    return (
        filtered()
        .groupby("species")[input.y()]
        .agg(Mean="mean", Median="median", SD="std")
        .round(2)
        .reset_index()
    )

@render.plot
def plot():
    return ggplot(filtered(), aes(x=input.x(), y=input.y(), color="species")) + ...

@render.text
def count():
    return f"{len(filtered())} penguins selected"

@render.data_frame
def table():
3    return summary()
1
First calc: filters rows
2
Second calc: derives stats from filtered(), also reads Y Axis
3
Table only calls summary(), no filtering logic here
Tip🎯 Live Demo

Run code/demos/app-04-reactivity_chained.py.

When to use what

Need Tool
Filter / transform shared by multiple outputs @reactive.calc
Derive from another calc (chain) @reactive.calc calling another calc
Trigger side effects (write file, log, etc.) @reactive.effect
Store mutable state reactive.value
Render a plot / table / text @render.*

Demo + Exercises

See exercise: Reactivity.

Code

code/demos/app-03-reactivity.py

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]

from palmerpenguins import load_penguins
from plotnine import aes, geom_point, ggplot, theme_minimal
from shiny import reactive
from shiny.express import input, render, ui

dat = load_penguins().dropna()
species = dat["species"].unique().tolist()
num_cols = dat.select_dtypes("float64").columns.tolist()

ui.input_checkbox_group("species", "Species", species, selected=species, inline=True)
ui.input_select("x", "X", num_cols, selected="bill_depth_mm")
ui.input_select("y", "Y", num_cols, selected="body_mass_g")


@reactive.calc
def filtered():
    print("filtering...")
    return dat[dat["species"].isin(input.species())]


@render.plot
def plot():
    return (
        ggplot(filtered(), aes(x=input.x(), y=input.y(), color="species"))
        + geom_point(alpha=0.7)
        + theme_minimal()
    )


@render.text
def count():
    return f"{len(filtered())} penguins selected"

code/demos/app-04-reactivity_chained.py

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]

from palmerpenguins import load_penguins
from plotnine import aes, geom_point, ggplot, theme_minimal
from shiny import reactive
from shiny.express import input, render, ui

dat = load_penguins().dropna()
species = dat["species"].unique().tolist()
num_cols = dat.select_dtypes("float64").columns.tolist()

with ui.sidebar():
    ui.input_checkbox_group("species", "Species", species, selected=species)
    ui.input_select("x", "X", num_cols, selected="bill_depth_mm")
    ui.input_select("y", "Y", num_cols, selected="body_mass_g")


@reactive.calc
def filtered():
    print("reactive calc: filtered()")
    return dat[dat["species"].isin(input.species())] # reactive to only species()


@reactive.calc
def summary():
    print("reactive calc: summary()")
    return (
        filtered() # << reactive to filtered()
        .groupby("species")[input.y()] # << reactive to input.y()
        .agg(Mean="mean", Median="median", SD="std")
        .round(2)
        .reset_index()
    )


@render.plot
def plot():
    return (
        ggplot(filtered(), aes(x=input.x(), y=input.y(), color="species"))
        + geom_point(alpha=0.7)
        + theme_minimal()
    )


@render.text
def count():
    return f"{len(filtered())} penguins selected"


@render.data_frame
def table():
    return summary()