Interactive reporting with Shiny (I)

Lecture 22

Dr. Benjamin Soltoff

Cornell University
INFO 3312/5312 - Spring 2026

April 16, 2026

Announcements

Announcements

  • Homework 06
  • Project drafts
  • Remaining class schedule

Learning objectives

  • Introduce Shiny for interactive web applications
  • Review implementation of basic Shiny app
  • Introduce reactive programming concepts
  • Utilize {bslib} for modern Shiny UI design

Application exercise

ae-21

Instructions

  • Go to the course GitHub org and find your ae-21 (repo name will be suffixed with your GitHub name).
  • Clone the repo in Positron, run renv::restore() to install the required packages, open the Quarto document in the repo, and follow along and complete the exercises.
  • Render, commit, and push your edits by the AE deadline – end of the day

Communicating interactively with data

  1. Interactive charts
  2. Scrollytelling
  3. Dashboards
  4. Interactive web applications

Shiny: High level view

Every Shiny app has a webpage that the user visits,
and behind this webpage there is a computer that serves this webpage by running R (or Python!).

When running your app locally, the computer serving your app is your computer.

When your app is deployed, the computer serving your app is a web server.

Dating rules

Age gaps

Dating rules

Anatomy of a Shiny app

Shiny

  • Shiny is a web application framework for R (and Python) that allows you to build interactive web applications using R (or Python) code
  • Host standalone apps on a webpage, embed them in Quarto documents, or build dashboards
  • Extend Shiny apps with CSS themes, {htmlwidgets}, and JavaScript actions

shiny hex logo

{bslib}

Modern UI toolkit for Shiny based on Bootstrap

bslib hex logo

Shiny + {bslib}

library(shiny)
library(bslib)

is now the officially recommended way to build Shiny apps.

  • {shiny} functions uses camelCase
  • {bslib} functions use snake_case

App components

library(shiny)
library(bslib)

ui = list()

server = function(input, output, session) {}

shinyApp(ui = ui, server = server)

Every Shiny app has two main components:

  • A UI (user interface) which controls how the app looks

  • and a server function which controls how the app works.

Weather data

read_csv("data/weather.csv")
# A tibble: 23,376 × 17
     id name  airport_code country state region longitude latitude date       temp_avg temp_min
  <dbl> <chr> <chr>        <chr>   <chr> <chr>      <dbl>    <dbl> <date>        <dbl>    <dbl>
1 72202 Miami KMIA         US      FL    South      -80.3     25.8 2022-01-01     24.2     21.1
2 72202 Miami KMIA         US      FL    South      -80.3     25.8 2022-01-02     24.4     21.7
3 72202 Miami KMIA         US      FL    South      -80.3     25.8 2022-01-03     22.7     17.8
4 72202 Miami KMIA         US      FL    South      -80.3     25.8 2022-01-04     20.4     16.7
5 72202 Miami KMIA         US      FL    South      -80.3     25.8 2022-01-05     22.9     19.4
6 72202 Miami KMIA         US      FL    South      -80.3     25.8 2022-01-06     21.4     16.7
7 72202 Miami KMIA         US      FL    South      -80.3     25.8 2022-01-07     23.6     20.6
# ℹ 23,369 more rows
# ℹ 6 more variables: temp_max <dbl>, precip <dbl>, snow <dbl>, wind_direction <dbl>,
#   wind_speed <dbl>, air_press <dbl>

Demo 01 - Our first app

demos/demo-01.R

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

d <- read_csv(here::here("ae/ae-21-weather/data/weather.csv"))

ui <- page_sidebar(
  title = "Temperatures at Major Airports",
  sidebar = sidebar(
    radioButtons(
      inputId = "name",
      label = "Select an airport",
      choices = c(
        "Hartsfield-Jackson Atlanta",
        "Raleigh-Durham",
        "Denver",
        "Los Angeles",
        "John F. Kennedy"
      )
    )
  ),
  plotOutput(outputId = "plot")
)

server <- function(input, output, session) {
  output$plot <- renderPlot({
    d |>
      filter(name %in% input$name) |>
      ggplot(mapping = aes(x = date, y = temp_avg)) +
      geom_line()
  })
}

shinyApp(ui = ui, server = server)

⌨️ Your turn - exercise 01

Instructions

Open exercises/ex-01.R and execute it via the Run App button.

Check that you are able to successfully run the Shiny app and are able to interact with it by picking a new airport.

If everything is working, try modifying the code:

  • Try adding or removing a city from radioButtons()
  • Check what happens if you add a city that is not in weather.csv (i.e. Ithaca)
04:00

UIs

Layouts

Shiny uses page layout functions as a way to specify the general organization of the UI of an app.

Our first app used a sidebar layout created with page_sidebar(). This layout consists of:

  • a title,

  • a sidebar (which usually holds inputs or extra information),

  • and then a collection of other Shiny UI elements which form the body of the app (a plot in our case).

This sidebar layout is a good place to start for most simple apps, but there are many other layouts that can be used to create more complex and interesting apps.

Shiny layouts

Shiny vs {bslib} layouts

Since page_sidebar() uses snake_case we can infer it is from {bslib}. If you’ve seen older Shiny code you may have seen fluidPage() + sidebarLayout() which come from {shiny}.

There are analogues of almost all of Shiny’s original page layouts in {bslib}, either choice is viable and will let you create a working Shiny app.

We have opted to use the {bslib} layouts because they are more modern and typically have a more user friendly (less nested) syntax.

UI functions are HTML

One of the neat tricks of Shiny is that the interface is just a webpage, and this can be seen by the fact that UI functions are just R functions that generate HTML.

We can run any of the following in our console and see the underlying HTML of each element.

actionButton("id", "Click me!")
selectInput("test", "Test", choices = c("A", "B", "C"), selectize = FALSE)
img(src = "shiny.png")
<button id="id" type="button" class="btn btn-default action-button">
  <span class="action-label">Click me!</span>
</button>
<div class="form-group shiny-input-container">
  <label class="control-label" id="test-label" for="test">Test</label>
  <div>
    <select class="shiny-input-select form-control" id="test"><option value="A" selected>A</option>
<option value="B">B</option>
<option value="C">C</option></select>
  </div>
</div>
<img src="shiny.png"/>

Inputs and outputs

Shiny input widgets

Shiny inputs

sliderInput(
  inputId = "num",
  label = "Choose a number",
  min = 0,
  max = 100,
  value = 20
)
  • Input name
  • Label to display
  • Input-specific arguments

⌨️ Your turn - exercise 02

Instructions

Starting from the code in exercises/ex-02.R try changing the radioButton() input to one of the following:

  • selectInput() with multiple = FALSE

  • selectInput() with multiple = TRUE

  • checkboxGroupInput()

What happens? Does the app still work as expected?

06:00

Shiny output widgets

Basic reactivity

Reactive elements

demos/demo-01.R

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

d <- read_csv(here::here("ae/ae-21-weather/data/weather.csv"))

ui <- page_sidebar(
  title = "Temperatures at Major Airports",
  sidebar = sidebar(
    radioButtons(
      inputId = "name",
      label = "Select an airport",
      choices = c(
        "Hartsfield-Jackson Atlanta",
        "Raleigh-Durham",
        "Denver",
        "Los Angeles",
        "John F. Kennedy"
      )
    )
  ),
  plotOutput(outputId = "plot")
)

server <- function(input, output, session) {
  output$plot <- renderPlot({
    d |>
      filter(name %in% input$name) |>
      ggplot(mapping = aes(x = date, y = temp_avg)) +
      geom_line()
  })
}

shinyApp(ui = ui, server = server)

Outputs

plot

Inputs

name

inputs

outputs

Our inputs and outputs are defined by the elements in our ui.

Reactive graph

demos/demo-01.R

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

d <- read_csv(here::here("ae/ae-21-weather/data/weather.csv"))

ui <- page_sidebar(
  title = "Temperatures at Major Airports",
  sidebar = sidebar(
    radioButtons(
      inputId = "name",
      label = "Select an airport",
      choices = c(
        "Hartsfield-Jackson Atlanta",
        "Raleigh-Durham",
        "Denver",
        "Los Angeles",
        "John F. Kennedy"
      )
    )
  ),
  plotOutput(outputId = "plot")
)

server <- function(input, output, session) {
  output$plot <- renderPlot({
    d |>
      filter(name %in% input$name) |>
      ggplot(mapping = aes(x = date, y = temp_avg)) +
      geom_line()
  })
}

shinyApp(ui = ui, server = server)

Outputs

Inputs

name

plot

The reactive logic is defined in our server() function - {shiny} takes care of figuring out what depends on what.

Demo 02 - adding an input

demos/demo-02.R

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

d <- read_csv(here::here("ae/ae-21-weather/data/weather.csv"))

d_vars <- c(
  "Average temp" = "temp_avg",
  "Min temp" = "temp_min",
  "Max temp" = "temp_max",
  "Total precip" = "precip",
  "Snow depth" = "snow",
  "Wind direction" = "wind_direction",
  "Wind speed" = "wind_speed",
  "Air pressure" = "air_press"
)

ui <- page_sidebar(
  title = "Weather Forecasts",
  sidebar = sidebar(
    radioButtons(
      inputId = "name",
      label = "Select an airport",
      choices = c(
        "Raleigh-Durham",
        "Houston Intercontinental",
        "Denver",
        "Los Angeles",
        "John F. Kennedy"
      )
    ),
    selectInput(
      inputId = "var",
      label = "Select a variable",
      choices = d_vars,
      selected = "temp_avg"
    )
  ),
  plotOutput(outputId = "plot")
)

server <- function(input, output, session) {
  output$plot <- renderPlot({
    d |>
      filter(name %in% input$name) |>
      ggplot(mapping = aes(x = date, y = .data[[input$var]])) +
      geom_line() +
      labs(title = str_c(input$name, "-", input$var))
  })
}

shinyApp(ui = ui, server = server)

.data pronoun

.data[[input$var]]

A useful feature from {rlang} that can be used across much of the {tidyverse} to reference columns in a data frame using a variable.

This helps avoid some of the complexity around “non-standard evaluation” (e.g. {{, !!, enquo(), etc.) when working with functions built with tidyeval (e.g. {dplyr} and {ggplot2}).

Reactive graph

With these additions, what does our reactive graph look like now?

Outputs

Inputs

name

var

plot

⌨️ Your turn - exercise 03

Instructions

Starting with the code in exercises/ex-03.R (based on demo-02.R’s code) add a tableOutput() with the id minmax to the app’s body.

Once you have done that, you should then add logic to the server() function to render the table so that it shows the min and max average temperature for each year contained in the data.

  • You will need to add an appropriate output in the ui
  • And a corresponding reactive expression in the server() function to generate these summaries.
  • lubridate::year() will be useful along with dplyr::summarize().
10:00

Reactive graph (again)



Outputs

Inputs

var

name

plot

minmax

reactive()

DRY (Don’t repeat yourself)

Some of you may have noticed that in ex-03-A.R we have a bit of repeated code - specifically the filtering of d to subset the data for the selected airport.

While this is not a big deal here, it can become problematic in more complex apps.

exercises/ex-03-A.R

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

d <- read_csv(here::here("ae/ae-21-weather/data/weather.csv"))

d_vars <- c(
  "Average temp" = "temp_avg",
  "Min temp" = "temp_min",
  "Max temp" = "temp_max",
  "Total precip" = "precip",
  "Snow depth" = "snow",
  "Wind direction" = "wind_direction",
  "Wind speed" = "wind_speed",
  "Air pressure" = "air_press"
)

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

server <- function(input, output, session) {
  output$plot <- renderPlot({
    d |>
      filter(name %in% input$name) |>
      ggplot(mapping = aes(x = date, y = .data[[input$var]])) +
      geom_line() +
      labs(title = str_c(input$name, "-", input$var))
  })

  output$minmax <- renderTable({
    d |>
      filter(name %in% input$name) |>
      mutate(
        year = lubridate::year(date) |> as.integer()
      ) |>
      summarize(
        `min avg temp` = min(temp_min),
        `max avg temp` = max(temp_max),
        .by = year
      )
  })
}

shinyApp(ui = ui, server = server)

Demo 03 - Enter reactive()

demos/demo-03.R

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

d <- read_csv(here::here("ae/ae-21-weather/data/weather.csv"))

d_vars <- c(
  "Average temp" = "temp_avg",
  "Min temp" = "temp_min",
  "Max temp" = "temp_max",
  "Total precip" = "precip",
  "Snow depth" = "snow",
  "Wind direction" = "wind_direction",
  "Wind speed" = "wind_speed",
  "Air pressure" = "air_press"
)

ui <- page_sidebar(
  title = "Weather Forecasts",
  sidebar = sidebar(
    radioButtons(
      "name",
      "Select an airport",
      choices = c(
        "Raleigh-Durham",
        "Houston Intercontinental",
        "Denver",
        "Los Angeles",
        "John F. Kennedy"
      )
    ),
    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() +
      labs(title = str_c(input$name, "-", input$var))
  })

  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)

Another reactive expression

This is an example of a reactive conductor - it is a new type of reactive expression that exists between sources (e.g. an input) and endpoints (e.g. an output).

As such, a reactive() depends on various upstream inputs, returns a value of some kind which is used by 1 or more downstream outputs (or other conductors).

Their primary use is similar to a function in an R script, they help to

  • Avoid repeating ourselves

  • Decompose complex computations into smaller / more modular steps

  • Improve computational efficiency by breaking up / simplifying reactive dependencies

reactive() tips

  • Expressions are written in the same way as render*() functions but they do not have the output$ prefix.

    react_obj <- reactive({
      ...
    })
  • Any consumer of react_obj must access its value using react_obj() and not react_obj
    • Think of react_obj as a function that returns the current value

    • Common cause of the R error

      ## Error: object of type 'closure' is not subsettable`
  • Like input reactive expressions, may only be used within reactive contexts

    ## Error: Operation not allowed without an active reactive context. (You tried to do something that can only be done from inside a reactive expression or observer.)

Reactive graph



Outputs

Conductors

Inputs

var

city

d_city

plot

minmax

Observers

observer()

These are the final reactive expression we will be discussing. They are constructed in the same way as a reactive() however an observer does not return a value, instead they are used for their side effects.

  • Side effects in most cases involve sending data to the client browser, e.g. updating a UI element
  • While not obvious given their syntax - the results of the render*() functions are observers.

Demo 04 - filtering by region

demos/demo-04.R

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

d <- read_csv(here::here("ae/ae-21-weather/data/weather.csv"))

d_vars <- c(
  "Average temp" = "temp_avg",
  "Min temp" = "temp_min",
  "Max temp" = "temp_max",
  "Total precip" = "precip",
  "Snow depth" = "snow",
  "Wind direction" = "wind_direction",
  "Wind speed" = "wind_speed",
  "Air pressure" = "air_press"
)

ui <- page_sidebar(
  title = "Weather Forecasts",
  sidebar = sidebar(
    selectInput(
      "region",
      label = "Select a region",
      choices = c("West", "Midwest", "Northeast", "South")
    ),
    selectInput(
      "name",
      label = "Select an airport",
      choices = c()
    ),
    selectInput(
      "var",
      label = "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)
  })

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

  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)

Reactive graph

Outputs / Observers

Conductors

Inputs

name

var

region

d_city

plot

minmax

obs

Reactive graph - implicit

Using req()

You may have noticed that the App initializes with “West” selected for region but no initial selection for name. Because of this we have some warnings generated in the console:

# Warning: There were 2 warnings in `summarize()`.
# The first warning was:
# ℹ In argument: `min temp = min(temp_min)`.
# Caused by warning in `min()`:
# ! no non-missing arguments to min; returning Inf
# ℹ Run dplyr::last_dplyr_warnings() to see the 1 remaining warning.

This is a common occurrence with Shiny, particularly at initialization or when a user enters partial / bad input(s).

A good way to protect against this is to validate inputs before using them - the simplest way is to use req() which checks if a value is truthy and prevent further execution if not.

Truthiness in Shiny

In Shiny, “truthiness” determines whether a value should be considered valid for reactive execution.

A value is considered truthy if it is:

  • Not NULL
  • Not FALSE
  • Not an empty vector (character(0), numeric(0), etc.)
  • Not an empty string ""
  • Not NA

isTruthy(TRUE)
[1] TRUE
isTruthy(1)
[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

⌨️ Your turn - exercise 04

Instructions

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.

Also, think about if there are any other locations in our app where req() might be useful.

Tip

Thinking about how events “flow” through the reactive graph will be helpful here.

10:00

A note on observers

Reactive graphs are meant to be acyclic, that is they should not have circular dependencies.

The use of observers can introduce cycles (accidentally) which can then lead to infinite loops, see the following example:

library(shiny)

ui = fluidPage(
  numericInput("n", "n", 0)
)

server = function(input, output, session) {
  observeEvent(input$n, {
    updateNumericInput(inputId = "n", value = input$n + 1)
  })
}

shinyApp(ui = ui, server = server)

Wrap-up

Wrap-up

  • Shiny is a powerful tool for building interactive web applications with R, and {bslib} provides a modern and flexible way to design the UI of these apps
  • Reactive programming is a key concept in Shiny that allows for dynamic and responsive applications
  • Understanding how to use reactive() and observers is essential for building effective Shiny apps

Acknowledgements