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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,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. With more outputs this keeps getting 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
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
Rather than re-filtering inside the table render, we build a second calc that sits downstream of the first.
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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,color:#FFFFFF,font-weight:bold
classDef cached fill:#5833E9,color:#FFFFFF,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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,color:#FFFFFF,font-weight:bold
classDef cached fill:#5833E9,color:#FFFFFF,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:#101136,color:#F5F5F5,stroke:#3B3EA9
classDef active fill:#25C8EB,color:#090A28,font-weight:bold
classDef invalid fill:#D47454,color:#FFFFFF,font-weight:bold
classDef cached fill:#5833E9,color:#FFFFFF,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()β reads Y Axis too - 3
-
Table only calls
summary()β no filtering logic here
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
Demo: chained @reactive.calc
code/app-04-reactivity_chained.py β summary() derives from filtered(), feeding a third output. Change Y Axis and watch the count stay unchanged β filtered() is still cached.
#| '!! 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():
return dat[dat["species"].isin(input.species())]
@reactive.calc
def 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"))
+ geom_point(alpha=0.7)
+ theme_minimal()
)
@render.text
def count():
return f"{len(filtered())} penguins selected"
@render.data_frame
def table():
return summary()