Interactive reporting with Shiny II

Notes
Modified

May 13, 2026

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

Where we left off

From Shiny I, our app had two inputs (name, var) feeding one output (plot):

Inputs          Outputs
------          -------
name  --------> plot
var   ------/

This note extends the app with a second output, shared data preparation logic, and dynamic UI updates.

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:

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:

Inputs          Conductors     Outputs
------          ----------     -------
name  --------> d_city -------> plot
var   --------------------/
                d_city -------> 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.

Demo: add a “region” filter that dynamically updates the list of airports in the name input:

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:

Inputs          Conductors     Outputs / Observers
------          ----------     -------------------
region ---------------------------------> obs (updateSelectInput)
name  --------> d_city -------> plot
var   --------------------/
                d_city -------> minmax

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.

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:

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 Application Exercise

AE 22: Building a Shiny weather app

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.