3.2. Shiny Functions and Reactivity
Why Learn About Reactivity?
Imagine you’re creating a simple tool for Auckland Council planners. They select a neighbourhood from a dropdown menu, and instantly: - A map updates to show just that area - A table displays statistics for that neighbourhood - A chart shows population trends
The question is: How does the app “know” to update everything automatically when the user makes a selection?
This is what reactivity means in Shiny - it’s the magic that makes your app responsive without you having to write code that manually tells each piece to update.
Starting Simple: A Minimal Shiny App
Before we tackle reactivity, let’s understand the basic structure of a Shiny app. Think of it like building a house:
The Three Parts Every Shiny App Needs
- The UI (User Interface) - What users see and interact with (like the facade and rooms of a house)
- The Server - The behind-the-scenes logic (like the plumbing and electrical wiring)
- The App Object - The thing that brings UI and server together (like the house itself)
Visual representation:
┌─────────────────────────────────────────┐
│ USER'S BROWSER │
│ ┌───────────────────────────────────┐ │
│ │ UI Layer (What user sees) │ │
│ │ • Slider (input) │ │
│ │ • Text display (output) │ │
│ └──────────┬────────────────────────┘ │
└─────────────┼────────────────────────────┘
│ User moves slider
↓
┌─────────────────────────────────────────┐
│ PYTHON SERVER (Your code) │
│ ┌───────────────────────────────────┐ │
│ │ Server Function │ │
│ │ • Detects input change │ │
│ │ • Runs calculations │ │
│ │ • Updates output │ │
│ └──────────┬────────────────────────┘ │
└─────────────┼────────────────────────────┘
│ Sends new result
↓
Back to browser
Here’s the simplest possible Shiny app:
from shiny import App, ui, render
# Part 1: UI - What the user sees
app_ui = ui.page_fluid(
ui.h2("Hello Shiny!"), # A heading
ui.input_slider("number", "Pick a number:", 0, 100, 50), # A slider
ui.output_text("result") # A place to show results
)
# Part 2: Server - What happens behind the scenes
def server(input, output, session):
@render.text
def result():
return f"You picked: {input.number()}"
# Part 3: The App - Bring it all together
app = App(app_ui, server)What happens when you run this? 1. User moves the slider 2. Shiny notices input.number() changed 3. Shiny automatically re-runs the result() function 4. The text updates on screen
You didn’t write any code to detect the change or trigger the update - that’s reactivity!
Understanding Inputs and Outputs
Think of inputs and outputs like a conversation:
- Inputs = Questions you ask the user (“What number do you want?”)
- Outputs = Answers you show the user (“Here’s what you picked!”)
Common Input Types
Let’s see different ways to ask users for information:
app_ui = ui.page_fluid(
ui.h2("Different Ways to Get User Input"),
# 1. Slider - choose a number by sliding
ui.input_slider("age", "How old are you?", 0, 100, 25),
# 2. Text box - type freely
ui.input_text("name", "What's your name?", ""),
# 3. Dropdown menu - choose one option
ui.input_select(
"city",
"Which city?",
choices=["Auckland", "Wellington", "Christchurch"]
),
# 4. Checkbox - yes or no
ui.input_checkbox("agree", "I agree to terms", False),
# Show what the user selected
ui.output_text("summary")
)Common Output Types
Different ways to show information:
def server(input, output, session):
# Output 1: Simple text
@render.text
def summary():
return f"Name: {input.name()}, Age: {input.age()}"
# Output 2: A table
@render.table
def data_table():
return pd.DataFrame({
'Name': [input.name()],
'Age': [input.age()],
'City': [input.city()]
})
# Output 3: A plot
@render.plot
def age_chart():
fig, ax = plt.subplots()
ax.bar(['Your Age'], [input.age()])
return figWhat is Reactivity? A Simple Analogy
Imagine you’re a chef with a recipe board in your kitchen:
Without reactivity (traditional programming): 1. Customer orders → you manually check the order 2. You manually decide what to cook 3. You manually update each dish 4. You manually tell the waiter it’s ready
With reactivity (Shiny): 1. Customer orders → everything else happens automatically 2. Recipes automatically adjust to the order 3. Dishes automatically prepare 4. Waiter automatically knows when it’s ready
In Shiny, when an input changes, everything that depends on it automatically updates. You don’t write the “checking” code - Shiny handles it.
Your First Reactive Calculation
Sometimes you need to do a calculation and use the result in multiple places. Instead of repeating the calculation, you create a reactive calculation:
Without (reactive.calc?) (inefficient):
User moves slider
↓
┌─────────────────────┐
│ OUTPUT 1: count() │
│ • Filter suburbs │ ← Filtering happens here
│ • Count them │
└─────────────────────┘
↓
┌─────────────────────┐
│ OUTPUT 2: table() │
│ • Filter suburbs │ ← Filtering happens AGAIN (wasteful!)
│ • Show table │
└─────────────────────┘
Problem: Filtering happens TWICE
With (reactive.calc?) (efficient):
User moves slider
↓
┌──────────────────────────┐
│ REACTIVE CALC │
│ filtered_suburbs() │
│ • Filter suburbs ONCE │ ← Filtering happens ONCE
└────────┬─────────────────┘
│
│ Both outputs share this result
│
┌────┴─────┐
↓ ↓
┌────────┐ ┌────────┐
│count() │ │table() │
└────────┘ └────────┘
Benefit: Filtering happens ONCE, used TWICE
from shiny import App, ui, render, reactive
import pandas as pd
# Some data about Auckland suburbs
suburbs = pd.DataFrame({
'name': ['Ponsonby', 'Parnell', 'Mt Eden', 'Newmarket'],
'population': [12500, 8900, 15300, 11200],
'area_km2': [2.1, 1.8, 3.4, 2.7]
})
app_ui = ui.page_fluid(
ui.h2("Auckland Suburbs"),
ui.input_slider("min_pop", "Minimum Population:", 0, 20000, 0, step=1000),
ui.output_text("count"),
ui.output_table("suburb_table")
)
def server(input, output, session):
# Reactive calculation: filter suburbs based on minimum population
# This calculates ONCE and can be used MULTIPLE times below
@reactive.calc
def filtered_suburbs():
min_population = input.min_pop()
return suburbs[suburbs['population'] >= min_population]
# Output 1: Show how many suburbs match
@render.text
def count():
data = filtered_suburbs() # Use the reactive calculation
return f"Found {len(data)} suburbs"
# Output 2: Show the table
@render.table
def suburb_table():
return filtered_suburbs() # Use the SAME reactive calculation
app = App(app_ui, server)Why use @reactive.calc?
Without it, if you filtered suburbs in both count() and suburb_table(), the filtering would happen TWICE. With @reactive.calc, it happens ONCE and both outputs use the same result. More efficient!
Building Up: A Simple Geospatial Example
Now let’s work with actual spatial data. We’ll build this step by step:
Step 1: Load Data Outside the Server
Data that doesn’t change should be loaded once, before the server function:
Visual explanation:
APP STARTUP (happens once):
┌────────────────────────────────────┐
│ Load data │
│ regions = gpd.read_file(...) │ ← Happens ONCE
│ Calculate area │
│ Calculate density │
└────────────────┬───────────────────┘
│
│ Data loaded and ready
│
↓
┌────────────────────────────────────┐
│ Server function starts │
│ (data is already available) │
└────────────────────────────────────┘
│
│
USER INTERACTIONS:
│
┌────────────┼────────────┐
↓ ↓ ↓
Input 1 Input 2 Input 3
│ │ │
└────────────┴────────────┘
│
Uses pre-loaded data
(no need to reload!)
Why load data here?
❌ Wrong way (inside server):
def server(input, output, session):
@reactive.calc
def filtered_regions():
regions = gpd.read_file(...) # ← Reloads every time! Very slow!
return regions[...]Every time the user changes something, the file loads again. If 10 users are using your app, the file loads 10+ times!
✓ Right way (outside server):
# Load once at startup
regions = gpd.read_file('data/auckland_sa2.gpkg') # ← Loads ONCE
def server(input, output, session):
@reactive.calc
def filtered_regions():
return regions[...] # ← Uses already-loaded dataFile loads once when app starts. All users share the same loaded data. Much faster!
Step 2: Create a Simple UI
app_ui = ui.page_fluid(
ui.h2("Auckland Population Density"),
# Simple filter: minimum population
ui.input_slider(
"min_pop",
"Minimum Population:",
min=0,
max=50000,
value=0,
step=5000
),
# Show a map
ui.output_plot("density_map"),
# Show statistics
ui.output_text("stats")
)Step 3: Add Server Logic
def server(input, output, session):
# Reactive: filter regions based on user input
@reactive.calc
def filtered_regions():
"""Keep only regions with population >= minimum"""
min_pop = input.min_pop()
return regions[regions['population'] >= min_pop]
# Output: create the map
@render.plot
def density_map():
data = filtered_regions()
# Create a simple map
fig, ax = plt.subplots(figsize=(10, 8))
data.plot(
column='pop_density',
ax=ax,
legend=True,
cmap='YlOrRd'
)
ax.set_title(f'Population Density ({len(data)} regions)')
ax.axis('off')
return fig
# Output: show statistics
@render.text
def stats():
data = filtered_regions()
avg_density = data['pop_density'].mean()
total_pop = data['population'].sum()
return f"Average density: {avg_density:.1f} people/km². Total population: {total_pop:,.0f}"
# Create the app
app = App(app_ui, server)What’s happening here?
- User moves the slider →
input.min_pop()changes - Shiny notices
filtered_regions()depends oninput.min_pop() - Shiny recalculates
filtered_regions() - Shiny notices both
density_map()andstats()depend onfiltered_regions() - Shiny updates both the map and statistics automatically
You wrote no code to manage this chain of updates!
The Reactive Graph
Shiny tracks what depends on what. Here’s a visual way to think about it:
┌─────────────────────────────────────────────────────────┐
│ USER ACTION │
│ 👤 User moves slider to 10,000 │
└────────────────┬────────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────┐
│ INPUT (Reactive Source) │
│ 📊 input.min_pop() = 10000 │
└────────────────┬────────────────────────────────────────┘
│
│ Shiny detects this changed
│
↓
┌─────────────────────────────────────────────────────────┐
│ REACTIVE CALCULATION │
│ ⚙️ filtered_regions() │
│ • Reads input.min_pop() │
│ • Filters data │
│ • Returns filtered DataFrame │
└────────────────┬────────────────────────────────────────┘
│
│ Shiny knows these depend on filtered_regions()
│
┌───────┴────────┐
↓ ↓
┌────────────────┐ ┌─────────────────┐
│ OUTPUT 1 │ │ OUTPUT 2 │
│ 🗺️ density_map() │ │ 📝 stats() │
│ Uses filtered │ │ Uses filtered │
│ data to plot │ │ data for text │
└────────────────┘ └─────────────────┘
│ │
└───────┬────────┘
↓
Both re-render on screen
What happens step-by-step:
- User action → Slider moves from 5,000 to 10,000
- Shiny detects → “input.min_pop() has changed!”
- Shiny checks → “What depends on input.min_pop()?”
- Finds →
filtered_regions()uses it - Recalculates →
filtered_regions()runs once - Shiny checks again → “What depends on filtered_regions()?”
- Finds → Both
density_map()andstats()use it - Updates outputs → Both re-render with new data
Why this is efficient: - filtered_regions() only calculates once (not twice) - Only things that depend on the changed input update - If you had an output that doesn’t use filtered_regions(), it wouldn’t update at all
Common Patterns for Beginners
Pattern 1: One Input, One Output
┌──────────────┐
│ USER │
│ Types "Bob" │
└──────┬───────┘
│
↓
┌──────────────────┐
│ input.name() │ ← Reactive source
└──────┬───────────┘
│
↓
┌──────────────────┐
│ greeting() │ ← Output function
│ "Hello, Bob!" │
└──────┬───────────┘
│
↓
Shown on screen
The simplest pattern:
app_ui = ui.page_fluid(
ui.input_text("your_name", "Name:"),
ui.output_text("greeting")
)
def server(input, output, session):
@render.text
def greeting():
return f"Hello, {input.your_name()}!"Pattern 2: One Input, Multiple Outputs
┌──────────────────┐
│ USER │
│ Selects 50 │
└────────┬─────────┘
│
↓
┌────────────────────┐
│ input.value() │ ← One reactive source
│ = 50 │
└────────┬───────────┘
│
│ Both outputs depend on this
│
┌────┴────┐
↓ ↓
┌─────────┐ ┌─────────┐
│doubled()│ │squared()│ ← Two outputs
│= 100 │ │= 2500 │
└─────────┘ └─────────┘
│ │
└────┬────┘
↓
Both shown on screen
One input updates multiple outputs:
app_ui = ui.page_fluid(
ui.input_slider("value", "Pick a number:", 0, 100, 50),
ui.output_text("doubled"),
ui.output_text("squared")
)
def server(input, output, session):
@render.text
def doubled():
return f"Double: {input.value() * 2}"
@render.text
def squared():
return f"Squared: {input.value() ** 2}"Pattern 3: Multiple Inputs, One Output
app_ui = ui.page_fluid(
ui.input_text("first_name", "First name:"),
ui.input_text("last_name", "Last name:"),
ui.output_text("full_name")
)
def server(input, output, session):
@render.text
def full_name():
return f"{input.first_name()} {input.last_name()}"A Complete Beginner Example: Auckland Suburbs Explorer
Let’s put it all together with a realistic but simple example:
from shiny import App, ui, render, reactive
import pandas as pd
import matplotlib.pyplot as plt
# Sample data
suburbs = pd.DataFrame({
'name': ['Ponsonby', 'Parnell', 'Mt Eden', 'Newmarket', 'Grey Lynn', 'Remuera'],
'population': [12500, 8900, 15300, 11200, 14800, 9600],
'median_age': [34, 42, 38, 41, 36, 45],
'median_income': [75000, 95000, 68000, 82000, 71000, 105000]
})
# UI
app_ui = ui.page_fluid(
ui.h2("Auckland Suburbs Explorer"),
ui.p("Filter suburbs and explore their characteristics"),
# Filters
ui.input_slider(
"min_population",
"Minimum Population:",
min=0,
max=20000,
value=0,
step=2000
),
ui.input_slider(
"min_income",
"Minimum Median Income:",
min=60000,
max=110000,
value=60000,
step=5000
),
ui.hr(), # A horizontal line
# Outputs
ui.h4("Results"),
ui.output_text("summary"),
ui.output_table("filtered_table"),
ui.output_plot("income_chart")
)
# Server
def server(input, output, session):
# Reactive: filter suburbs
@reactive.calc
def filtered_suburbs():
"""Apply both population and income filters"""
data = suburbs.copy()
data = data[data['population'] >= input.min_population()]
data = data[data['median_income'] >= input.min_income()]
return data
# Output 1: Text summary
@render.text
def summary():
data = filtered_suburbs()
n = len(data)
if n == 0:
return "No suburbs match your filters. Try relaxing the criteria."
else:
avg_pop = data['population'].mean()
return f"Found {n} suburbs with average population of {avg_pop:,.0f}"
# Output 2: Table
@render.table
def filtered_table():
data = filtered_suburbs()
# Format for display
display = data[['name', 'population', 'median_age', 'median_income']].copy()
display.columns = ['Suburb', 'Population', 'Median Age', 'Median Income ($)']
return display
# Output 3: Chart
@render.plot
def income_chart():
data = filtered_suburbs()
if len(data) == 0:
# Show message if no data
fig, ax = plt.subplots(figsize=(8, 4))
ax.text(0.5, 0.5, 'No data to display',
ha='center', va='center', fontsize=14)
ax.axis('off')
return fig
# Create bar chart
fig, ax = plt.subplots(figsize=(8, 5))
ax.barh(data['name'], data['median_income'], color='steelblue')
ax.set_xlabel('Median Income ($)')
ax.set_title('Income by Suburb')
plt.tight_layout()
return fig
# Create app
app = App(app_ui, server)What makes this work?
- Two sliders provide input: minimum population and minimum income
- One reactive calculation (
filtered_suburbs()) applies both filters - Three outputs all use the same filtered data:
- Text summary shows how many suburbs match
- Table shows the matching suburbs
- Chart visualises their incomes
- When either slider moves, everything updates automatically
What You’ve Learned
- Shiny apps have three parts: UI (what users see), server (logic), and app (combines them)
- Inputs get information from users: sliders, text boxes, dropdowns, etc.
- Outputs show information to users: text, tables, plots, etc.
- Reactivity is automatic: when an input changes, outputs update automatically
- Reactive calculations share logic: use
@reactive.calcto avoid repeating calculations - The reactive graph: Shiny tracks what depends on what
Practice Exercise
Try modifying the suburbs explorer example:
- Add another filter (e.g., maximum median age)
- Add a new output showing the suburb with highest income
- Change the chart to show population instead of income
- Add a checkbox to show/hide the chart
Next Steps
Now that you understand the basics of reactivity, you can: - Add more complex filters - Work with real spatial data from GeoPandas - Create multi-page applications - Learn about advanced reactive patterns
The key insight: you focus on WHAT should happen, and Shiny handles WHEN it happens.