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
- 1
-
@reactive.calcallows you to createfiltered(): 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 includeinput.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_framefed 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()