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

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

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

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_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:#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: shared @reactive.calc

code/app-03-reactivity.py β€” species filter shared between a scatter plot and a count.

#| '!! 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():
    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"

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()