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

  1. The UI (User Interface) - What users see and interact with (like the facade and rooms of a house)
  2. The Server - The behind-the-scenes logic (like the plumbing and electrical wiring)
  3. 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 fig

What 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 data

File 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?

  1. User moves the slider → input.min_pop() changes
  2. Shiny notices filtered_regions() depends on input.min_pop()
  3. Shiny recalculates filtered_regions()
  4. Shiny notices both density_map() and stats() depend on filtered_regions()
  5. 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:

  1. User action → Slider moves from 5,000 to 10,000
  2. Shiny detects → “input.min_pop() has changed!”
  3. Shiny checks → “What depends on input.min_pop()?”
  4. Findsfiltered_regions() uses it
  5. Recalculatesfiltered_regions() runs once
  6. Shiny checks again → “What depends on filtered_regions()?”
  7. Finds → Both density_map() and stats() use it
  8. 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()}"

Pattern 4: Reactive Calculation for Shared Logic

app_ui = ui.page_fluid(
    ui.input_slider("value", "Pick a number:", 0, 100, 50),
    ui.output_text("analysis"),
    ui.output_text("category")
)

def server(input, output, session):
    
    # Shared calculation
    @reactive.calc
    def processed_value():
        return input.value() * 1.5  # Some calculation
    
    @render.text
    def analysis():
        val = processed_value()
        return f"Processed: {val}"
    
    @render.text  
    def category():
        val = processed_value()
        if val < 50:
            return "Low"
        else:
            return "High"

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?

  1. Two sliders provide input: minimum population and minimum income
  2. One reactive calculation (filtered_suburbs()) applies both filters
  3. Three outputs all use the same filtered data:
    • Text summary shows how many suburbs match
    • Table shows the matching suburbs
    • Chart visualises their incomes
  4. 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.calc to avoid repeating calculations
  • The reactive graph: Shiny tracks what depends on what

Practice Exercise

Try modifying the suburbs explorer example:

  1. Add another filter (e.g., maximum median age)
  2. Add a new output showing the suburb with highest income
  3. Change the chart to show population instead of income
  4. 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.

Further Reading