Shiny Reactivity

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

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

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

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

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

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

🎯 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

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

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

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

🎯 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.