Interactive reporting with Shiny II

Notes
Modified

June 14, 2026

ImportantGetting started
  • Go to the course GitHub org and find your ae-shiny (repo name will be suffixed with your GitHub name).
  • Clone the repo in Positron, follow along, and complete the exercises embedded throughout the notes.
NoteLearning objectives
  • Implement complex reactive features in Shiny
  • Use reactive() to avoid repeated code
  • Use observe() for side effects
  • Validate inputs with req() and validate()
  • Add download and upload functionality

reactive() — DRY reactive expressions

The problem: repeated code

As apps grow, it is common to compute the same filtered dataset in multiple render*() calls:

server <- function(input, output, session) {
  output$plot <- renderPlot({
    d |>
      filter(name %in% input$name) |>
      ggplot(...)
  })

  output$minmax <- renderTable({
    d |>
      filter(name %in% input$name) |>
      summarize(...)
  })
}
1
The same filter expression appears in both render functions. When input$name changes, Shiny re-runs both expressions — the same filtering happens twice.

The solution: reactive()

reactive() creates a reactive conductor — an intermediate computation that can be shared across multiple downstream consumers:

demos/demo-03.R

library(tidyverse)
library(shiny)
library(bslib)

d <- read_csv("data/weather.csv")

d_vars <- c(
  "Average temp" = "temp_avg",
  "Min temp" = "temp_min",
  "Max temp" = "temp_max",
  "Total precip" = "precip",
  "Wind speed" = "wind_speed"
)

ui <- page_sidebar(
  title = "Weather Forecasts",
  sidebar = sidebar(
    radioButtons(
      "name",
      "Select an airport",
      choices = c("Raleigh-Durham", "Denver", "Los Angeles", "JFK")
    ),
    selectInput(
      "var",
      "Select a variable",
      choices = d_vars,
      selected = "temp_avg"
    )
  ),
  plotOutput("plot"),
  tableOutput("minmax")
)

server <- function(input, output, session) {

  d_city <- reactive({

    d |> filter(name %in% input$name)
  })

  output$plot <- renderPlot({
    d_city() |>
      ggplot(mapping = aes(x = date, y = .data[[input$var]])) +
      geom_line()
  })

  output$minmax <- renderTable({
    d_city() |>
      mutate(year = year(date) |> as.integer()) |>
      summarize(
        `min avg temp` = min(temp_min),
        `max avg temp` = max(temp_max),
        .by = year
      )
  })
}

shinyApp(ui = ui, server = server)
1
reactive({...}) wraps a computation and caches its result. The expression re-executes only when input$name changes.
2
Consumers access the reactive’s value using d_city() — note the parentheses, as if calling a function. Using d_city without parentheses returns the reactive object itself, not its value, which causes the error: Error: object of type 'closure' is not subsettable.

Reactive graph with a conductor

The reactive graph now has an intermediate node:

Outputs

Conductors

Inputs

name

var

d_city

plot

minmax

d_city is evaluated once when input$name changes; both plot and minmax use the cached result.

reactive() rules

  • Must be created inside the server() function, not at the top level
  • May only be read from within other reactive contexts (other reactive(), render*(), or observe() calls)
  • Always accessed with () — not with the bare name
  • Is lazily evaluated and cached: it only runs when a downstream consumer requests its value, and returns the cached value until an upstream dependency changes

observe() — reactive side effects

Dynamic UI updates

A reactive conductor returns a value. An observer does not — it is used purely for its side effects. The most common side effect is updating a UI element.

For example, let’s add a “region” filter that dynamically updates the list of airports in the name input:

demos/demo-04.R

ui <- page_sidebar(
  title = "Weather Forecasts",
  sidebar = sidebar(
    selectInput(
      "region",
      "Select a region",
      choices = c("West", "Midwest", "Northeast", "South")
    ),
    selectInput(
      "name",
      "Select an airport",
      choices = c()
    ),
    selectInput(
      "var",
      "Select a variable",
      choices = d_vars,
      selected = "temp_avg"
    )
  ),
  plotOutput("plot"),
  tableOutput("minmax")
)

server <- function(input, output, session) {

  observe({

    updateSelectInput(

      session = session,
      inputId = "name",
      choices = d |>
        distinct(region, name) |>
        filter(region == input$region) |>
        pull(name)
    )
  })

  d_city <- reactive({
    d |> filter(name %in% input$name)
  })

  # ... render functions unchanged ...
}
1
The airport choices start empty — they will be populated by the observer.
2
observe({...}) runs whenever any reactive value inside it changes. Here it reads input$region and calls updateSelectInput() to repopulate the "name" dropdown with airports in the selected region.

The updated reactive graph:

Outputs / Observers

Conductors

Inputs

region

name

var

d_city

plot

minmax

obs

observe() rules

  • Runs for its side effects, not its return value
  • Like reactive(), must be inside server() and may only read reactive values from reactive contexts
  • Runs eagerly: it executes as soon as any dependency changes, even if no output currently needs it
  • render*() functions are observers — they update a UI output as a side effect

Input validation

req()

Shiny apps execute reactive expressions on initialization, often before the reader has made a selection. This can produce warnings or errors from downstream computations operating on empty or NULL inputs.

req() silently halts execution of a reactive expression if a value is not “truthy”:

d_city <- reactive({
  req(input$name)
  d |> filter(name %in% input$name)
})
1
If input$name is NULL, "", or an empty vector, req() stops execution silently. The output simply stays blank rather than showing an error.

Truthiness

In Shiny, a value is truthy if it is not NULL, not FALSE, not an empty vector, not "", and not NA:

isTruthy(TRUE)
[1] TRUE
isTruthy("hello")
[1] TRUE
isTruthy(c(1, 2, 3))
[1] TRUE
isTruthy(NULL)
[1] FALSE
isTruthy(FALSE)
[1] FALSE
isTruthy("")
[1] FALSE
isTruthy(character(0))
[1] FALSE
isTruthy(NA)
[1] FALSE

req() vs validate()

req() silently stops execution; validate() stops execution and displays an error message in the UI:

# req() — silent stop
d_city <- reactive({
  req(input$name)
  d |> filter(name %in% input$name)
})

# validate() — show error message
d_city <- reactive({
  validate(
    need(input$name, "Please select an airport")
  )
  d |> filter(name %in% input$name)
})
1
need(condition, message) evaluates the condition; if it is not truthy, the message is displayed in the output area.

Use req() for initialization guards (where silence is appropriate) and validate() when the reader has provided input that fails a logical check and deserves an informative message.

Note⌨️ Your turn

Using the code provided in exercises/ex-04.R (based on demos/demo-04.R) as a starting point, add the calls to req() necessary to avoid the initialization warnings that appear when the app loads before any airport is selected.

Think about where in the reactive graph req() is most effective — consider how events “flow” from inputs through conductors to outputs.

Downloading data from Shiny

downloadButton() and downloadHandler()

downloadButton() is a special UI widget that triggers a file download. Unlike other inputs that return values, it is paired with downloadHandler() in the server:

demos/demo-05.R

ui <- page_sidebar(
  title = "Weather Forecasts",
  sidebar = sidebar(
    selectInput("region", "Region", choices = sort(unique(d$region))),
    selectInput("name", "Airport", choices = c()),
    selectInput("var", "Variable", choices = d_vars),
    downloadButton("download")
  ),
  plotOutput("plot")
)

server <- function(input, output, session) {

  output$download <- downloadHandler(

    filename = function() {

      name <- input$name |>
        str_replace_all(" ", "_") |>
        str_to_lower()
      str_c(name, ".csv")
    },

    content = function(file) {

      write_csv(d_city(), file)
    }
  )

  d_city <- reactive({
    req(input$name)
    d |> filter(name %in% input$name)
  })

  # ... observer and render functions ...
}
1
downloadButton("download") creates the UI button with ID "download".
2
downloadHandler() is assigned to output$download, linking it to the button. It takes two functions: filename and content.
3
content(file) receives a temporary file path and writes the data to it. Shiny sends this file to the browser as a download.

Controlling the reactive graph

bindEvent()

By default, Shiny automatically detects reactive dependencies — any reactive value read inside a render*(), reactive(), or observe() becomes a dependency. To explicitly control when an expression re-executes, use bindEvent():

output$plot <- renderPlot({
  d_city() |>
    ggplot(aes(x = date, y = .data[[input$var]])) +
    geom_line()
}) |>
  bindEvent(input$update_btn)
1
bindEvent(input$update_btn) means the plot only re-renders when the update_btn action button is clicked — not when d_city() or input$var change.

bindEvent() works with reactive(), observe(), and render*() functions. When binding a reactive object, use the functional form: d_city() not d_city.

Avoiding circular reactive graphs

Reactive graphs must be acyclic — no circular dependencies. Observers that update inputs can accidentally create cycles:

server <- function(input, output, session) {
  observe({
    updateNumericInput(inputId = "n", value = input$n + 1)
  }) |>
    bindEvent(input$n)
}
1
This observer reads input$n and immediately updates input$n. Each update triggers the observer again — an infinite loop. Use bindEvent() carefully and consider whether an update is truly needed.

File uploads

fileInput()

fileInput() lets readers upload files to the server. Before a file is uploaded, input$<id> returns NULL. After upload, it returns a data frame with one row per file:

Column Description
name Original filename on the reader’s system
size File size in bytes
type MIME type (e.g., "text/csv")
datapath Path to the temporary file on the server
ui <- page_fluid(
  fileInput("upload", "Upload a file", accept = ".csv"),
  tableOutput("data")
)

server <- function(input, output, session) {
  output$data <- renderTable({
    req(input$upload)

    ext <- tools::file_ext(input$upload$datapath)
    validate(need(ext == "csv", "Please upload a CSV file"))

    read_csv(input$upload$datapath) |> head()
  })
}
1
accept = ".csv" suggests valid file types in the file picker (not a security guarantee).
2
req(input$upload) prevents execution before any file is uploaded.
3
validate() checks the file extension and shows a message if it is wrong.
4
input$upload$datapath is the server-side path to the uploaded file.

Key points:

  • datapath points to a temporary location — treat it as ephemeral; new uploads may overwrite previous files
  • Always validate uploaded file content — the accept argument is a UI hint, not a server-side filter
  • Anywhere that previously used the static d object must use d() if it becomes a reactive
Note⌨️ Your turn

Starting with the code in exercises/ex-05.R, replace the preloading of the weather data (d <- read_csv(...)) with a reactive() version that is populated via a fileInput() widget.

You should then be able to get the same app behavior as before once data/weather.csv is uploaded. You can also check that your app works with the smaller data/jfk_weather.csv dataset.

Remember that anywhere that uses d will now need to use d() instead.

Summary

  • reactive({...}) creates a reactive conductor — a cached, shared computation used by multiple downstream outputs. Always access its value with object(), not object.
  • observe({...}) is used for side effects only (e.g., updateSelectInput()). It returns no value.
  • req() silently halts execution if an input is not truthy; validate() / need() halts execution and displays an error message to the reader.
  • downloadButton() + downloadHandler() enable file downloads; fileInput() enables file uploads.
  • bindEvent() lets you explicitly specify which inputs trigger a reactive expression.

Acknowledgements

Material derived in part from posit::conf 2025 - Shiny for R Workshop by Colin Rundel (CC BY 4.0) and Advanced Data Visualization.