flowchart LR
A[/input_slider/] --> C{{filtered_data}}
B[/input_checkbox_group/] --> C
C --> D([total_tippers])
C --> E([average_tip])
C --> F([avg_bill])
style A fill:#FFF3E0,stroke:#FF9800
style B fill:#FFF3E0,stroke:#FF9800
style C fill:#F3E5F5,stroke:#9C27B0
style D fill:#E3F2FD,stroke:#2196F3
style E fill:#E3F2FD,stroke:#2196F3
style F fill:#E3F2FD,stroke:#2196F3
Review 1 — Reactivity Basics
Inputs → reactive.calc → Outputs
This review covers the core reactivity pattern in Shiny for Python: how inputs connect to outputs, why the decorators matter, and how @reactive.calc lets you share filtered data across multiple outputs.
By the end of this review, you will remind yourself/learn how to:
- Describe the three-part rule connecting a UI output widget to its server render function
- Identify which connection part is absent in a broken app
- Spot an ID mismatch when all parts are present but something is still silent
- Connect up a skeleton app spots using correct decorator and input syntax
- Refactor duplicated filter logic into @reactive.calc and classify when caching helps
Work through each section in order. Every quiz block gives you instant feedback with a pointer back to the relevant lecture slides.
All blocks are built from app examples we covered in lectures. If you want to play with them more, there are links to the app code in repository.
A. App Anatomy
A Shiny (Core) app has a specific structure. Look at the code below from our skeleton app. It contains a UI input but no output. Read it carefully, then answer the ordering question that follows:
from shiny import App, ui
app_ui = ui.page_fluid(
ui.input_radio_buttons(
id="species",
label="Species",
choices=["Adelie", "Gentoo", "Chinstrap"],
)
)
def server(input, output, session):
pass
app = App(app_ui, server)App: code/lecture01/app-01-core-radio.py · Lecture: slides 02-first_shiny
Put the key ingredients of a Shiny Core app above in the correct order:
The correct order is:
- Import the framework’s core building blocks
- Build the page layout with widgets for user input and placeholders for output
- Write the server logic that reads user choices and produces results
- Bind the layout and the logic together into a runnable app
The standard Shiny Core pattern is: import -> create the UI tree -> define the server function -> bind them with App(). Each step depends on the previous one: you can’t reference ui until it’s imported, and you can’t pass app_ui and server to App() until both are defined. See: slides 02-first_shiny, slides 5-10.
B. What’s missing?
Now that you know the structure, let’s see what happens when parts are missing.
Every Shiny output requires three things working together: (1) a ui.output_*() placeholder in the UI that reserves space on the page, (2) a server function decorated with the matching @render.* decorator, and (3) an input.id() call inside that function to read the user’s selection. All three must be present - missing any one silently breaks the connection. See: slides 02-first_shiny, slides 11-13.
This app looks like it should work: there is an input and the server builds a chart. But it has three connection errors. Read the code carefully and try to spot all three, then select them in the quiz below:
from shiny import ui, render, App
import altair as alt
from palmerpenguins import load_penguins
dat = load_penguins().dropna()
app_ui = ui.page_fluid(
ui.input_radio_buttons(
id="species",
label="Species",
choices=["Adelie", "Gentoo", "Chinstrap"],
inline=True,
)
)
def server(input, output, session):
species = "Gentoo" # selected species
sel = dat.loc[dat.species == species] # selected data
# Base histogram for all data
base = (
alt.Chart(dat)
.mark_bar(color="#C2C2C4", binSpacing=0)
.encode(
alt.X("bill_length_mm:Q", bin=alt.Bin(step=1), title="bill_length_mm"),
alt.Y("count()", title="count"),
)
)
# Overlay histogram for selected species
overlay = (
alt.Chart(sel)
.mark_bar(color="#447099", binSpacing=0)
.encode(alt.X("bill_length_mm:Q", bin=alt.Bin(step=1)), alt.Y("count()"))
)
return base + overlay
app = App(app_ui, server)App: code/lecture01/app-03-core-altair-error.py · Lecture: slides 02-first_shiny
C. Fix It Yourself
You identified the three problems in Section B. Now fix them in the live editor below. The app starts broken — edit the code and click ▶ to check that the app responds correctly. When all three fixes are correct, the chart will react to the radio buttons. Watch for TODOs in code. If you need extra hints, see them below the editor.
#| '!! 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 shiny import ui, render, App
import altair as alt
from palmerpenguins import load_penguins
from shinywidgets import render_altair, output_widget
dat = load_penguins().dropna()
app_ui = ui.page_fluid(
ui.input_radio_buttons(
id="species",
label="Species",
choices=["Adelie", "Gentoo", "Chinstrap"],
inline=True,
)
# TODO: add an output placeholder here
)
def server(input, output, session):
species = "Gentoo" # TODO: read from input instead
sel = dat.loc[dat.species == species]
# Base histogram for all data
base = (
alt.Chart(dat)
.mark_bar(color="#C2C2C4", binSpacing=0)
.encode(
alt.X("bill_length_mm:Q", bin=alt.Bin(step=1), title="bill_length_mm"),
alt.Y("count()", title="count"),
)
)
# Overlay histogram for selected species
overlay = (
alt.Chart(sel)
.mark_bar(color="#447099", binSpacing=0)
.encode(alt.X("bill_length_mm:Q", bin=alt.Bin(step=1)), alt.Y("count()"))
)
return base + overlay
app = App(app_ui, server)
Is there an
output_widget()call in your UI layout?WarningFix 1: Add output placeholderAdd an output placeholder
output_widget("plot")in the UI so Shiny knows where to display the chart.NoteExact codeAdd
output_widget("plot")after the radio buttons, separated by a comma:app_ui = ui.page_fluid( ui.input_radio_buttons( id="species", label="Species", choices=["Adelie", "Gentoo", "Chinstrap"], inline=True, ), output_widget("plot"), # <-- add this )Does your render function’s name match the output placeholder id?
WarningFix 2: Add render decoratorWrap the chart code in a render decorator
@render_altairso Shiny captures the output. The function name must match the output placeholder id ("plot").NoteExact codedef server(input, output, session): @render_altair def plot(): # <-- name matches output_widget("plot") # ... chart code goes here ... return base + overlayAre you reading
input.species()(with parentheses) instead of a hardcoded value?WarningFix 3: Read from inputRead the user’s selection from the input widget
input.species()instead of hardcoding a species name.NoteExact codeReplace the hardcoded
"Gentoo"withinput.species()inside the render function:species = input.species() # <-- reads user's selectionNote the parentheses —
input.species()returns the current value, whileinput.species(without parentheses) would return the reactive object itself.
The id-matching pattern you just applied is exactly what Section D will test — but with a subtler mismatch where every piece is present yet the output stays blank. You now have the rule; Section D shows why it matters in a larger codebase.
C.2 Why Output IDs Matter
You fixed three bugs in the editor above. Now let’s deepen your understanding: what is the one thing that must never change when you modify an app?
D. Dashboard Skeleton
In Section C you fixed three connection errors. There is one more rule that catches almost everyone — and unlike the previous three, it produces no error message: the app runs, but an output stays blank.
Read the skeleton app below to get familiar with the structure. After that, read the next code chunk, which represents your fellow student’s attempt to connect the first value box to the server. Then, answer the following question.
from shiny import App, ui
# UI
app_ui = ui.page_fillable(
ui.panel_title("Restaurant tipping"),
ui.layout_sidebar(
ui.sidebar("sidebar inputs", open="desktop"),
ui.layout_columns(
ui.value_box("Total tippers", "Value 1"),
ui.value_box("Average tip", "Value 2"),
ui.value_box("Average bill", "Value 3"),
fill=False,
),
ui.layout_columns(
ui.card(ui.card_header("Tips data"), full_screen=True),
ui.card(ui.card_header("Total bill vs tip"), full_screen=True),
col_widths=[6, 6],
),
ui.layout_columns(
ui.card(ui.card_header("Tip percentages"), full_screen=True)
),
),
)
# Server
def server(input, output, session):
pass
# Create app
app = App(app_ui, server)App: code/lecture02/core/app-01-ui.py · Lecture: slides 04-dashboard-core
Now suppose your fellow student tries to make the first value box work. They add ui.output_text("tippers") inside it and write a render function in the server. Read the code chunk and help them to spot an error in the quiz below.
app_ui = ui.page_fillable(
ui.panel_title("Restaurant tipping"),
ui.layout_sidebar(
ui.sidebar(
ui.input_slider("slider", "Bill amount",
min=0, max=60, value=[0, 60]),
),
ui.layout_columns(
ui.value_box("Total tippers", ui.output_text("tippers")),
ui.value_box("Average tip", "Value 2"),
ui.value_box("Average bill", "Value 3"),
fill=False,
),
),
)
def server(input, output, session):
@render.text
def total_count():
return tips.shape[0]In Section B, you found missing pieces — components that were never created. Here every piece is present, but two don’t match. This is a subtler class of bug because Shiny fails silently — no error, just a blank.
Shiny connects a UI output placeholder to a server render function by matching string ids: the id you pass to ui.output_text("my_id") must be identical to the name of the decorated render function (def my_id():). The match is case-sensitive and must be exact, or it will return a blank component.
The UI has ui.output_text("tippers") but the server defines def total_count(). Rename the function to match the id:
@render.text
def tippers(): # <-- was total_count; now matches "tippers"
return tips.shape[0]E. The reactive.calc Pattern
You can now connect a single output to inputs. But what happens when multiple outputs need the same filtered data? You could copy the filter logic into each render function — but there is a better way.
Before diving in, test your memory on the connection rules from Sections C and D:
Now answer this warm-up about the new concept:
Now study the app below. It has one value box that filters the tips dataset. After reading the code, answer the quiz, then refactor the app in the editor below:
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
import pandas as pd
import seaborn as sns
from shiny import App, reactive, render, ui
tips = sns.load_dataset("tips")
# UI
app_ui = ui.page_fillable(
ui.panel_title("Restaurant tipping"),
ui.layout_sidebar(
ui.sidebar(
ui.input_slider(
id="slider",
label="Bill amount",
min=tips.total_bill.min(),
max=tips.total_bill.max(),
value=[tips.total_bill.min(), tips.total_bill.max()],
),
ui.input_checkbox_group(
id="checkbox_group",
label="Food service",
choices={
"Lunch": "Lunch",
"Dinner": "Dinner",
},
selected=[
"Lunch",
"Dinner",
],
),
ui.input_action_button("action_button", "Reset filter"),
),
ui.layout_columns(
ui.value_box("Total tippers", ui.output_text("total_tippers")),
fill=False,
),
),
)
# Server
def server(input, output, session):
@render.text
def total_tippers():
idx1 = tips.total_bill.between(
left=input.slider()[0],
right=input.slider()[1],
inclusive="both",
)
idx2 = tips.time.isin(input.checkbox_group())
tips_filtered = tips[idx1 & idx2]
return tips_filtered.shape[0]
# Create app
app = App(app_ui, server)
The app below already has a second output (average_tip) added to the UI. The filter logic is currently duplicated in both render functions. Refactor it: add a @reactive.calc called filtered_data() and call it from both outputs.
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
import pandas as pd
import seaborn as sns
from shiny import App, reactive, render, ui
tips = sns.load_dataset("tips")
# UI
app_ui = ui.page_fillable(
ui.panel_title("Restaurant tipping"),
ui.layout_sidebar(
ui.sidebar(
ui.input_slider(
id="slider",
label="Bill amount",
min=tips.total_bill.min(),
max=tips.total_bill.max(),
value=[tips.total_bill.min(), tips.total_bill.max()],
),
ui.input_checkbox_group(
id="checkbox_group",
label="Food service",
choices={"Lunch": "Lunch", "Dinner": "Dinner"},
selected=["Lunch", "Dinner"],
),
ui.input_action_button("action_button", "Reset filter"),
),
ui.layout_columns(
ui.value_box("Total tippers", ui.output_text("total_tippers")),
ui.value_box("Avg tip ($)", ui.output_text("average_tip")),
fill=False,
),
),
)
# Server
def server(input, output, session):
# TODO 1: Add a @reactive.calc function called filtered_data() here.
# Move the filter logic from the render functions into it
# and return the filtered DataFrame.
@render.text
def total_tippers():
# TODO 2: Replace the five lines below with one call to filtered_data()
idx1 = tips.total_bill.between(
left=input.slider()[0],
right=input.slider()[1],
inclusive="both",
)
idx2 = tips.time.isin(input.checkbox_group())
tips_filtered = tips[idx1 & idx2]
return tips_filtered.shape[0]
@render.text
def average_tip():
# TODO 3: Replace the five lines below with one call to filtered_data()
idx1 = tips.total_bill.between(
left=input.slider()[0],
right=input.slider()[1],
inclusive="both",
)
idx2 = tips.time.isin(input.checkbox_group())
tips_filtered = tips[idx1 & idx2]
return round(tips_filtered.tip.mean(), 2)
# Create app
app = App(app_ui, server)
@reactive.calc
Did you create a
@reactive.calcdecorated function calledfiltered_data()?WarningStep 1: Addfiltered_data()Insert a
@reactive.calcdecorated function insideserver, above the two render functions. It should contain the filtering logic andreturnthe filtered DataFrame.NoteExact codeAdd this block inside
server, before the render functions:@reactive.calc def filtered_data(): idx1 = tips.total_bill.between( left=input.slider()[0], right=input.slider()[1], inclusive="both", ) idx2 = tips.time.isin(input.checkbox_group()) return tips[idx1 & idx2]Did you replace the duplicate filter code in each render function with a call to
filtered_data()?WarningStep 2: Simplifytotal_tippers()Replace the five filter lines with a single call
filtered_data()(note the parentheses), then return.shape[0]on the result.NoteExact codeReplace the body of
total_tippers()with one line:@render.text def total_tippers(): return filtered_data().shape[0]Are both render functions now calling the same calc instead of filtering independently?
WarningStep 3: Simplifyaverage_tip()Same pattern: call
filtered_data()and return the mean tip rounded to two decimal places.NoteExact codeReplace the body of
average_tip()with one line:@render.text def average_tip(): return round(filtered_data().tip.mean(), 2)
You now know how to extract shared logic into @reactive.calc. Section F will test whether you understand when and why to use it — and when you don’t need it. You have the how; Section F focuses on the why.
F. Why reactive.calc?
To wrap up, let’s make sure you understand why the @reactive.calc pattern exists and when to reach for it. Answer these four questions about caching, app structure, and when to use @reactive.calc:
Summary
The core connection pattern in Shiny for Python:
- UI layer: Place input widgets (
ui.input_*) and output placeholders (ui.output_*oroutput_widget) in the page layout. - Server layer: Write render functions decorated with
@render.*whose function names match the output placeholder ids. - Read inputs: Inside render functions, call
input.id()to read the current value of an input widget. - Share computed values: When multiple outputs need the same data, use
@reactive.calcto compute it once and cache it.
Next up: Review 2 — Tables & Charts Together covers DataGrid as a reactive source, selection → chart highlighting, and filter cascades.