Learning Objectives

Use the Shiny framework to develop online interactive applications accepting user input to render outputs from arbitrary R functions.

2.1 Install packages

Let’s begin by installing and loading the packages used for this workshop. This will already be installed if you’re using Posit Cloud.

options(repos = c(
    tbeptech = 'https://tbep-tech.r-universe.dev',
    CRAN = 'https://cloud.r-project.org'))

# install and load
install.packages(c('leaflet', 'shiny', 'plotly', 'tbeptools', 'tidyverse'))
library(leaflet)
library(plotly)
library(shiny)
library(tbeptools)
library(tidyverse)

2.2 A simple example

We’ll start with a simple application of Shiny. As with most problems, it’s good to start with identifying where you want to go and then work backwards to figure out how to get there. Let’s end with a simple histogram to visualize some data for the normal distribution, but with different sample sizes. To start, we create the simple plot with the random data of a given sample size.

dat <- rnorm(100)
hist(dat)

Changing the sample size:

dat <- rnorm(10)
hist(dat)

To make a Shiny app out of this, we need to identify our inputs and outputs. The input in this case is what we want to be able to modify (the sample size) and the output is the plot. This can all be done in a single script by creating a ui and server component. Inputs and outputs go in the ui object. The server takes the inputs, does something with them, then sends the results back to the ui.

Using our template from before:

library(shiny)
ui <- fluidPage()
server <- function(input, output){}
shinyApp(ui = ui, server = server)

Then putting this into our template would look something like this:

library(shiny)

ui <- fluidPage(
  numericInput(inputId = 'n', label = 'Sample size', value = 50),
  plotOutput('myplot')
)

server <- function(input, output){
  output$myplot <- renderPlot({
    dat <- rnorm(input$n)
    hist(dat)
  })
}

shinyApp(ui = ui, server = server)

Okay, so what is happening under the hood when you change the sample size?

  1. The input value n (you name it) for the server is chosen by the user from the ui. This uses the numericInput() widget.

    ui <- fluidPage(
      numericInput(inputId = 'n', label = 'Sample size', value = 50),
      plotOutput('myplot')
    )
  2. Shiny recognizes that the input$n value comes from the ui and is used by the server to create the random sample dat.

    server <- function(input, output){
      output$myplot <- renderPlot({
        dat <- rnorm(input$n)
        hist(dat)
      })
    }
  3. The dat object with sample size n is then used to create a histogram.

    server <- function(input, output){
      output$myplot <- renderPlot({
        dat <- rnorm(input$n)
        hist(dat)
      })
    }
  4. The plot output named myplot (you name it) is created using the renderPlot() function and appended to the output list of objects in the server function.

    server <- function(input, output){
      output$myplot <- renderPlot({
        dat <- rnorm(input$n)
        hist(dat)
      })
    }
  5. The plot is then rendered on the ui using plotOutput by referencing the myplot name from the output object

    ui <- fluidPage(
      numericInput(inputId = 'n', label = 'Sample size', value = 50),
      plotOutput('myplot')
    )

This is what it looks like in a simple flowchart.

All of this happens each time the input values are changed, such that the output reacts to any change in the input. This is a fundamental principle of Shiny reactivity, simplified as follows.

2.3 Using the RStudio template

Another useful way of learning the basics of the ui and server is to use the built-in Shiny template in RStudio. Under File -> New File -> Shiny Web App…, you can open a script that has a working Shiny app. Tinkering with this file will teach you a lot about how Shiny works.

For now, let’s go with the single file option that puts the entire application in app.R rather than splitting it intwo (ui.R, server.R).

Let’s try it again from scratch, recreating our simple histogram example. Here’s what the template file looks like:

#
# This is a Shiny web application. You can run the application by clicking
# the 'Run App' button above.
#
# Find out more about building applications with Shiny here:
#
#    https://shiny.posit.co/
#

library(shiny)

# Define UI for application that draws a histogram
ui <- fluidPage(

    # Application title
    titlePanel("Old Faithful Geyser Data"),

    # Sidebar with a slider input for number of bins 
    sidebarLayout(
        sidebarPanel(
            sliderInput("bins",
                        "Number of bins:",
                        min = 1,
                        max = 50,
                        value = 30)
        ),

        # Show a plot of the generated distribution
        mainPanel(
           plotOutput("distPlot")
        )
    )
)

# Define server logic required to draw a histogram
server <- function(input, output) {

    output$distPlot <- renderPlot({
        # generate bins based on input$bins from ui.R
        x    <- faithful[, 2]
        bins <- seq(min(x), max(x), length.out = input$bins + 1)

        # draw the histogram with the specified number of bins
        hist(x, breaks = bins, col = 'darkgray', border = 'white',
             xlab = 'Waiting time to next eruption (in mins)',
             main = 'Histogram of waiting times')
    })
}

# Run the application 
shinyApp(ui = ui, server = server)

We can replace the relevant pieces with those used in our initial histogram app. What does this app do differently from the original?

Your final product should look like this:

library(shiny)

# Define UI for application that draws a histogram
ui <- fluidPage(

    # Application title
    titlePanel("My awesome histogram app"),

    # Sidebar with a numeric input for sample size 
    sidebarLayout(
        sidebarPanel(
           numericInput(inputId = 'n', label = 'Sample size', value = 50)
        ),

        # Show a plot of the generated distribution
        mainPanel(
           plotOutput("distPlot")
        )
    )
)

# Define server logic required to draw a histogram
server <- function(input, output) {

    output$distPlot <- renderPlot({
        
      # generate random sample of size n
        x <- rnorm(input$n)

        # draw the histogram
        hist(x)
        
    })
}

# Run the application 
shinyApp(ui = ui, server = server)

This is the same app as before, but the layout is different and we’ve only replaced the relevant pieces, i.e., the title, the numeric input widget, and a simpler plot.

Let’s spice it up by adding a widget for changing the histogram color. There’s a lot to say about “widgets” - Shiny has many you can choose from depending on the type of input you need. This page provides an overview of available widgets. We’ll add the selectInput() widget in this example.

library(shiny)

# Define UI for application that draws a histogram
ui <- fluidPage(

    # Application title
    titlePanel("My awesome histogram app"),

    # Sidebar with a numeric input for sample size 
    sidebarLayout(
        sidebarPanel(
           numericInput(inputId = 'n', label = 'Sample size', value = 50), 
           selectInput(inputId = 'col', label = 'Choose color', choices = c('red', 'blue', 'green'))
        ),

        # Show a plot of the generated distribution
        mainPanel(
           plotOutput("distPlot")
        )
    )
)

# Define server logic required to draw a histogram
server <- function(input, output) {

    output$distPlot <- renderPlot({
        
      # generate random sample of size n
        x <- rnorm(input$n)

        # draw the histogram
        hist(x, col = input$col)
        
    })
}

# Run the application 
shinyApp(ui = ui, server = server)

Notice how the random sample changes when you update the color. Why is that? How can we fix this?

2.4 Showcase mode

A useful tool provided by Shiny is running an application in showcase mode. This is a literal demonstration of the workflow outlined above, but in realtime as you run the app. To use showcase mode, you must first save your app as an R script in a folder of the same name (app). Use the function shiny::runApp() and add display.mode = 'showcase' in the call.

shiny::runApp('app', display.mode = 'showcase')

This is a simple example that isn’t very informative, but it gives you a sense of how showcase made works. It will be more useful with a more complex app.

2.5 Debugging a Shiny app

Figuring out why and where your code might break in an application is challenging because of the reactivity inherent in a Shiny app. You can’t just run the app step by step as you would a normal R script. The browser() function can be used to “step inside” the R code of an app. The browser() function can work with any arbitrary R function that is part of your environment. We’ll first demonstrate how to use it with a simple function.

my_func <- function(n){

  x <- rnorm(n)
  hist(x)
  
}
my_func(n = 100)

Let’s save this code in a new R script, place the browser() function inside of my_func(), and run the code again.

my_func <- function(n){

  browser()
  x <- rnorm(n)
  hist(x)
  
}
my_func(n = 100)

Two things happen when the code is run:

  1. The source code now highlights the browser() function with a green arrow. This indicates your current location inside the browser.

  2. The console will show that you’re in the browser.

  3. Browser controls appear at the top of the console.

You can now “step” through the function using these controls or by pressing “enter”. The code will run as intended if there are no errors. You will exit the browser once the code execution is complete or if you hit the “Stop” button on the controls. Any objects created inside the function will be available for you to investigate if there are issues.

The browser() works the same with a Shiny app. You’ll use it within objects in the server component since these behave as functions. For example:

# Define server logic required to draw a histogram
server <- function(input, output) {
  
  output$distPlot <- renderPlot({
    browser()
    # generate random sample of size n
    x <- rnorm(input$n)
    
    # draw the histogram
    hist(x, col = input$col)
    
  })
}

The browser takes a bit of practice to get comfortable, but you’ll quickly find it useful for debugging apps, particularly those that have multiple working parts. For more information on debugging, checkout the article Debugging Shiny applications

2.6 Create a complex Shiny app

Now that we know the basics of a Shiny app and how to debug, we’ll create a more involved example similar to one you may encounter with water quality data. For example, a common use of Shiny is to plot data where there are multiple options to choose the type of data you have, e.g., multiple sampling stations and multiple parameters. These types of plots are often included as hundreds of pages in a report appendix and we can greatly simplify how the data are explored using Shiny. In essence, Shiny can be used to easily subset parts of the data that are of interest rather than producing all possible combinations.

In this next more complicated app we’ll create an interactive time series plot and map. Users can select a station from a drop-down menu and see the time series for any of the available indicators from another drop-down selection. The map will show the location of the selected station.

Complex app to create using water quality data from Tampa Bay, FL.

Complex app to create using water quality data from Tampa Bay, FL.

Here is the full set of expandable code for the app:

Show Code: wq/app.R
# Goal: Create an app to show a time series by station and indicator
#   includes map of selected station

# global.R ----

# * load libraries ----
library(tidyverse)
library(plotly)
library(leaflet)
library(tbeptools)

# * prep data ----
d <- epcdata |>
  select(
    station                 = epchc_station,
    SampleTime,
    lat                     = Latitude,
    lon                     = Longitude,
    `Total Nitrogen (mg/L)` = tn,
    `Chlorophyll-a (ug/L)`  = chla,
    `Secchi depth (m)`      = sd_m) |>
  pivot_longer(
    names_to  = "indicator",
    values_to = "value",
    `Total Nitrogen (mg/L)`:`Secchi depth (m)`)

# * data for select ----
stations   <- unique(d$station)
indicators <- unique(d$indicator)
locations  <- d |>
  select(station, lon, lat) |>
  unique()

#  ui.R ----
ui <- fluidPage(
  wellPanel(
    h2("Water Quality"),
    selectInput("sel_sta", "Station",   choices = stations),
    selectInput("sel_ind", "Indicator", choices = indicators),
    plotlyOutput("tsplot"),
    leafletOutput("map") )
)

#  server.R ----
server <- function(input, output, session) {
  
  # * get_data(): reactive to inputs ----
  get_data <- reactive({
    d |>
      filter(
        station   == input$sel_sta,
        indicator == input$sel_ind)
  })
  
  # * tsplot: time series plot ----
  output$tsplot <- renderPlotly({
    g <- ggplot(
      get_data(),
      aes(
        x = SampleTime,
        y = value) ) +
      geom_line() +
      labs(y = input$sel_ind)
    ggplotly(g)
  })
  
  # * map ----
  output$map <- renderLeaflet({
    
    # filter locations by station
    locs_sta <- locations |>
      filter(
        station == input$sel_sta)
    
    # create map
    leaflet(locations) |>
      addProviderTiles(providers$CartoDB.Positron) |>
      # add all stations
      addLabelOnlyMarkers(
        lat          = ~lat,
        lng          = ~lon,
        label        = ~as.character(station),
        labelOptions = labelOptions(
          noHide   = T,
          textOnly = T) ) |>
      # add selected station
      addCircles(
        data   = locs_sta,
        lng    = ~lon,
        lat    = ~lat,
        color  = "red",
        weight = 20)
  })
}

# run ----
shinyApp(ui, server)

You might notice the extra four dashes at the end of some of the comments in the code. Adding comments like these allows you to easily navigate to that section of code from the lower-left or Outline on the top-right of the Source pane in RStudio or using the Outline :

When comments end with four or more dashes ---- in the code, RStudio includes it as a menu item in the lower left of the Source pane to quickly jump to that section in the code. In this case menus are used to indicate content in app.R that might otherwise be split into seperate files: global.R, ui.R and server.R. Then an asterisk prefix * is used to bullet key sections within.

When comments end with four or more dashes ---- in the code, RStudio includes it as a menu item in the lower left of the Source pane to quickly jump to that section in the code. In this case menus are used to indicate content in app.R that might otherwise be split into seperate files: global.R, ui.R and server.R. Then an asterisk prefix * is used to bullet key sections within.

In this extended tutorial, we will explore more complex topics of reactivity, data wrangling with dplyr, and the advantages of using R libraries that wrap JavaScript functionality. We’ll start from the default Shiny application using the Old Faithful Geyser data and transform it into a more complex application.

2.6.1 Prepare data

First, let’s prepare the water quality data for our app from the Tampa Bay Estuary Program R package tbeptools that we’ll install if it’s not already installed on your computer.

library(tbeptools)
Registered S3 method overwritten by 'hoardr':
  method           from
  print.cache_info httr
epcdata
# A tibble: 27,961 × 26
   bay_segment epchc_station SampleTime             yr    mo Latitude Longitude
   <chr>               <dbl> <dttm>              <dbl> <dbl>    <dbl>     <dbl>
 1 HB                      6 2023-12-19 10:01:00  2023    12     27.9     -82.5
 2 HB                      7 2023-12-19 10:12:00  2023    12     27.9     -82.5
 3 HB                      8 2023-12-19 12:39:00  2023    12     27.9     -82.4
 4 MTB                     9 2023-12-19 11:54:00  2023    12     27.8     -82.4
 5 MTB                    11 2023-12-19 10:25:00  2023    12     27.8     -82.5
 6 MTB                    13 2023-12-19 10:38:00  2023    12     27.8     -82.5
 7 MTB                    14 2023-12-19 11:24:00  2023    12     27.8     -82.5
 8 MTB                    16 2023-12-20 09:35:00  2023    12     27.7     -82.5
 9 MTB                    19 2023-12-20 09:49:00  2023    12     27.7     -82.6
10 LTB                    23 2023-12-20 13:16:00  2023    12     27.7     -82.6
# ℹ 27,951 more rows
# ℹ 19 more variables: Total_Depth_m <dbl>, Sample_Depth_m <dbl>, tn <dbl>,
#   tn_q <chr>, sd_m <dbl>, sd_raw_m <dbl>, sd_q <chr>, chla <dbl>,
#   chla_q <chr>, Sal_Top_ppth <dbl>, Sal_Mid_ppth <dbl>,
#   Sal_Bottom_ppth <dbl>, Temp_Water_Top_degC <dbl>,
#   Temp_Water_Mid_degC <dbl>, Temp_Water_Bottom_degC <dbl>,
#   `Turbidity_JTU-NTU` <chr>, Turbidity_Q <chr>, Color_345_F45_PCU <chr>, …

The epcdata (?epcdata for details) object is a long-term time series starting in the 1970s collected monthly at numerous stations in Tampa Bay by the Environmental Protection Commission (EPC) of Hillsborough County. There are also numerous parameters that are measured.

The dataset is “lazily loaded” as part of the tbeptools R package, available once the package is loaded with library(tbeptools), similar to how faithful (?faithful for details) is automatically available from the base R package datasets.

How should we prepare epcdata for use with the dashboard? Remember, our goals are to be able to create a time series and map for a selected station and parameter:

  1. Let’s assume we only want three indicators: Total Nitrogen (mg/L) (tn), Chlorophyll-a (ug/L) (chla), and Secchi depth (m) (sd_m). Based on tidy principles, we want each row to capture a unique “observation” and any co-varying “variables” (such as location and time).

  2. The epcdata is currently in wide format, with each variable as its own column. We want to pivot it to long format so that each row is an observation of a single indicator. The dplyr package is the ‘swiss army knife’ (or ‘plyers’) for data wrangling, along with its close cousin tidyr. Let’s look at some basic operations: filtering, selecting, and pivoting. Be sure to reference Posit Cheatsheets like: Data tidying with tidyr :: Cheatsheet and Data transformation with dplyr :: Cheatsheet.

  3. Filter

    Reduce rows based on condition(s) that evaluate logically (i.e. True or False)

    d |> 
      dplyr::filter(station == 8)
  4. Select

    Reduce columns to only those specified

    d |> 
      dplyr::select(
        station = epchc_station, 
        SampleTime,
        lon     = Longitude,
        lat     = Latitude)
  5. Pivot

    Transform the data from wide to long format

    d |> 
      tidyr::pivot_longer(
        names_to  = "var", 
        values_to = "val")

Applying the above concepts, create a new folder app-wq for the water quality app and create the R file app-wq.R inside the folder with the following contents:

# * load libraries ----
library(leaflet)
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.4     ✔ readr     2.1.5
✔ forcats   1.0.0     ✔ stringr   1.5.1
✔ ggplot2   3.5.1     ✔ tibble    3.2.1
✔ lubridate 1.9.4     ✔ tidyr     1.3.1
✔ purrr     1.0.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(plotly)

Attaching package: 'plotly'

The following object is masked from 'package:ggplot2':

    last_plot

The following object is masked from 'package:stats':

    filter

The following object is masked from 'package:graphics':

    layout
library(leaflet)
library(tbeptools)

# * prep data ----
d <- epcdata |>
  select(
    station                 = epchc_station,
    SampleTime,
    lat                     = Latitude,
    lon                     = Longitude,
    `Total Nitrogen (mg/L)` = tn,
    `Chlorophyll-a (ug/L)`  = chla,
    `Secchi depth (m)`      = sd_m) |>
  pivot_longer(
    names_to  = "indicator",
    values_to = "value",
    `Total Nitrogen (mg/L)`:`Secchi depth (m)`)
d
# A tibble: 83,883 × 6
   station SampleTime            lat   lon indicator               value
     <dbl> <dttm>              <dbl> <dbl> <chr>                   <dbl>
 1       6 2023-12-19 10:01:00  27.9 -82.5 Total Nitrogen (mg/L)   0.305
 2       6 2023-12-19 10:01:00  27.9 -82.5 Chlorophyll-a (ug/L)    2.1  
 3       6 2023-12-19 10:01:00  27.9 -82.5 Secchi depth (m)      NaN    
 4       7 2023-12-19 10:12:00  27.9 -82.5 Total Nitrogen (mg/L)   0.276
 5       7 2023-12-19 10:12:00  27.9 -82.5 Chlorophyll-a (ug/L)    3    
 6       7 2023-12-19 10:12:00  27.9 -82.5 Secchi depth (m)        2.5  
 7       8 2023-12-19 12:39:00  27.9 -82.4 Total Nitrogen (mg/L)   0.805
 8       8 2023-12-19 12:39:00  27.9 -82.4 Chlorophyll-a (ug/L)   52.7  
 9       8 2023-12-19 12:39:00  27.9 -82.4 Secchi depth (m)        0.7  
10       9 2023-12-19 11:54:00  27.8 -82.4 Total Nitrogen (mg/L)   0.354
# ℹ 83,873 more rows

We additionally need to prepare data for the following elements:

  1. Stations
    List of unique station numbers for selecting from a drop-down menu.
  2. Indicators
    List of unique indicators for selecting from a drop-down menu.
  3. Locations
    List of unique station locations (i.e. stations with lat/lon coordinates) to display on the map.
# * data for select ----
stations   <- unique(d$station)
indicators <- unique(d$indicator)
locations  <- d |>
  select(station, lon, lat) |>
  unique()

stations
 [1]  6  7  8  9 11 13 14 16 19 23 24 25 28 32 33 36 38 40 41 44 46 47 50 51 52
[26] 55 60 63 64 65 66 67 68 70 71 73 80 81 82 84 90 91 92 93 95
indicators
[1] "Total Nitrogen (mg/L)" "Chlorophyll-a (ug/L)"  "Secchi depth (m)"     
locations
# A tibble: 45 × 3
   station   lon   lat
     <dbl> <dbl> <dbl>
 1       6 -82.5  27.9
 2       7 -82.5  27.9
 3       8 -82.4  27.9
 4       9 -82.4  27.8
 5      11 -82.5  27.8
 6      13 -82.5  27.8
 7      14 -82.5  27.8
 8      16 -82.5  27.7
 9      19 -82.6  27.7
10      23 -82.6  27.7
# ℹ 35 more rows

2.6.2 Add User Interface

Let’s add dropdown menus for station and indicator selection and placeholders for plotly and leaflet outputs. These outputs are htmlwidgets that allow additional interactivity. They will also be update based on user input.

#  ui.R ----
ui <- fluidPage(

  # * layout ----
  wellPanel(
    h2("Water Quality"),
    
    # * input widgets ----
    selectInput("sel_sta", "Station",   choices = stations),
    selectInput("sel_ind", "Indicator", choices = indicators),
    
    # * output htmlwidgets ----
    plotlyOutput("tsplot"),
    leafletOutput("map") )
)

Notice that shiny::fluidPage() and shiny::wellPanel() are used to setup the layout of the app. For more details, check out Shiny - Application layout guide. And for even more advanced layout options, checkout shinydashboard and bslib R packages.

Notice that plotly::plotlyOutput() and leaflet::leafletOutput() are used to layout the htmlwidgets. We need these components because we’ll be updating them interactively based on user input with server-side functions.

2.6.3 Add Server functions

The htmlwidget R packages made for Shiny generally have two functions: 1) an *Output to place in the ui; and 2) a render* function for the server based on user inputs:

  • general form: render* (server.R) -> *Output (ui.R)
  • plotly: renderPlotly() -> plotlyOutput()
  • leaflet: leafletOutput() -> renderLeaflet()

2.6.3.1 Render time series plot

Let’s add a renderPlotly() function to update the time series plot based on user inputs. The renderPlotly() function takes a plotly interactive plot object. We can use the plotly::ggplotly() function to take a static ggplot2 plot object and make it an interactive plotly object. Using ggplot2 allows us to take advantage of the Grammar of Graphics principles to render plots using a layered approach (see cheatsheet, summary or book).

The get_data() function allows us to generate a data frame reactive to user inputs and available for use across multiple server-side functions (although here we only use one). For more, see Shiny - Use reactive expressions.

#  server.R ----
server <- function(input, output, session) {

  # * get_data(): reactive to inputs ----
  get_data <- reactive({
    d |>
      filter(
        station   == input$sel_sta,
        indicator == input$sel_ind)
  })

  # * tsplot: time series plot ----
  output$tsplot <- renderPlotly({
    g <- ggplot(
      get_data(),
      aes(
        x = SampleTime,
        y = value) ) +
      geom_line() +
      labs(y = input$sel_ind)
    ggplotly(g)
  })
}

2.6.3.2 Render map

Let’s add a renderLeaflet() function to update the map based on user inputs. The renderLeaflet() function takes a leaflet interactive map object. We can use the leaflet::addLabelOnlyMarkers function to add station labels. And to highlight the selected station we can use leaflet::addCircles().

  # * map ----
  output$map <- renderLeaflet({

    # filter locations by station
    locs_sta <- locations |>
      filter(
        station == input$sel_sta)

    # create map
    leaflet(locations) |>
      addProviderTiles(providers$CartoDB.Positron) |>
      # add all stations
      addLabelOnlyMarkers(
        lat          = ~lat,
        lng          = ~lon,
        label        = ~as.character(station),
        labelOptions = labelOptions(
          noHide   = T,
          textOnly = T) ) |>
      # add selected station
      addCircles(
        data   = locs_sta,
        lng    = ~lon,
        lat    = ~lat,
        color  = "red",
        weight = 20)
  })

Now with all the pieces, your app should work as intended. Try running it by placing shinyApp(ui, server) at the bottom of your file and sourcing the entire script.

2.7 A dirty trick…

Much has changed in the programming world over the last few years. As you might expect, there are generative AI tools to help build and develop Shiny apps.