Skip to Content
Course content

43: Reactive Programming in Shiny

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time in standard R, you're used to linear execution: line one runs, then line two, then line three. Shiny throws that out the window. It uses a reactive graph, which is essentially a map of dependencies. When an input changes, Shiny looks at that map to figure out exactly which outputs need to be updated. If you treat a Shiny app like a linear script, you're going to run into performance bottlenecks that will make your app feel sluggish, even with small datasets.

The temptation to just put it in the render function

When I first started with Shiny, my instinct was to keep things simple. If I wanted a plot based on a filtered dataset, I'd put the filtering logic right inside the renderPlot block. It feels intuitive because everything related to that plot is in one place. Let's look at a scenario where we're analyzing a dataset of city temperatures. You have a slider for the year and a dropdown for the city.

# The naive approach
output$tempPlot <- renderPlot({
  # We filter the data inside the render function
  filtered_data <- my_big_dataset %>% 
    filter(year == input$selected_year, 
           city == input$selected_city)
  
  # Then we plot it
  ggplot(filtered_data, aes(x = month, y = temp)) + geom_line()
})

At first glance, this works perfectly. The plot updates when the year or city changes. But here is where the problem starts: what happens if you have a second output, like a table showing the summary statistics of that same filtered data? You'd likely copy-paste that filtering logic into a renderTable block. Now, every time a user changes an input, Shiny is filtering the same massive dataset twice. It's redundant, it's messy, and it's a waste of CPU cycles.

Why the "everything-in-one-place" approach kills performance

The real danger isn't just redundancy; it's the lack of caching. In the example above, the filtering logic is tied directly to the output. If you add a third input—say, a checkbox to toggle the plot's gridlines—the renderPlot function will trigger again. Shiny doesn't know that the gridline toggle doesn't affect the data filtering, so it re-runs the entire block, re-filtering the data from scratch just to change a line on the graph. I've seen apps crawl to a halt because they were re-calculating complex regressions or querying databases inside render functions every time a user clicked a trivial UI element.

Decoupling calculations with reactive expressions

The professional way to handle this is to move the "heavy lifting" into a reactive() expression. Think of a reactive expression as a smart variable. It doesn't just store a value; it remembers what inputs it depends on and it caches the result. If the inputs it depends on haven't changed, Shiny just hands back the cached value without re-running the code.

# The professional approach
# 1. Create a reactive expression for the filtered data
filtered_data <- reactive({
  # This only re-runs if input$selected_year or input$selected_city changes
  my_big_dataset %>% 
    filter(year == input$selected_year, 
           city == input$selected_city)
})

# 2. Use that reactive expression in your outputs
output$tempPlot <- renderPlot({
  # Note the parentheses: we call filtered_data() as a function
  ggplot(filtered_data(), aes(x = month, y = temp)) + geom_line()
})

output$tempTable <- renderTable({
  filtered_data() %>% summarize(avg_temp = mean(temp))
})

By doing this, we've created a single point of truth. If the user changes the city, the filtered_data reactive updates, which then triggers both the plot and the table to update. But if the user changes something unrelated—like that gridline checkbox—the renderPlot function still runs, but when it calls filtered_data(), Shiny says, "I already have that result cached," and skips the filtering step entirely. It's a massive win for efficiency.

When to use observeEvent instead of reactive

One final point that often trips people up: the difference between reactive() and observeEvent(). Use reactive() when you want to calculate a value to be used elsewhere. Use observeEvent() when you want to perform an action—a "side effect"—like showing a pop-up notification, saving a file to a disk, or updating another input value using updateSelectInput. If you find yourself trying to assign a value to a variable inside an observeEvent and then using that variable in a plot, you're fighting the framework. Switch back to a reactive expression.




📋 Practical Task

Refactoring the Global Weather Dashboard

You have been handed a legacy Shiny app that is performing poorly. Currently, the app filters a large weather dataset and calculates the monthly average temperature inside three separate render functions: renderPlot, renderText (for the max temperature), and renderTable (for the monthly breakdown). This means the filtering logic is running three times on every input change.

Your Task:

  • Identify the repeated filtering and calculation logic across the three output functions.
  • Create a single reactive() expression named processed_weather that handles the filtering and the average temperature calculation.
  • Update the renderPlot, renderText, and renderTable functions to call processed_weather() instead of recalculating the data.
  • Verify that the app still produces the same results but now utilizes Shiny's caching mechanism.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.