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 Fundamentals
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.
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.
<- rnorm(100)
dat hist(dat)
Changing the sample size:
<- rnorm(10)
dat 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)
<- fluidPage()
ui <- function(input, output){}
server shinyApp(ui = ui, server = server)
Then putting this into our template would look something like this:
library(shiny)
<- fluidPage(
ui numericInput(inputId = 'n', label = 'Sample size', value = 50),
plotOutput('myplot')
)
<- function(input, output){
server $myplot <- renderPlot({
output<- rnorm(input$n)
dat hist(dat)
})
}
shinyApp(ui = ui, server = server)
Okay, so what is happening under the hood when you change the sample size?
The
input
valuen
(you name it) for theserver
is chosen by the user from theui
. This uses thenumericInput()
widget.<- fluidPage( ui numericInput(inputId = 'n', label = 'Sample size', value = 50), plotOutput('myplot') )
Shiny recognizes that the
input$n
value comes from theui
and is used by theserver
to create the random sampledat
.<- function(input, output){ server $myplot <- renderPlot({ output<- rnorm(input$n) dat hist(dat) }) }
The
dat
object with sample sizen
is then used to create a histogram.<- function(input, output){ server $myplot <- renderPlot({ output<- rnorm(input$n) dat hist(dat) }) }
The plot output named
myplot
(you name it) is created using therenderPlot()
function and appended to theoutput
list of objects in theserver
function.<- function(input, output){ server $myplot <- renderPlot({ output<- rnorm(input$n) dat hist(dat) }) }
The plot is then rendered on the
ui
usingplotOutput
by referencing themyplot
name from theoutput
object<- fluidPage( ui 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
<- fluidPage(
ui
# 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
<- function(input, output) {
server
$distPlot <- renderPlot({
output# generate bins based on input$bins from ui.R
<- faithful[, 2]
x <- seq(min(x), max(x), length.out = input$bins + 1)
bins
# 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
<- fluidPage(
ui
# 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
<- function(input, output) {
server
$distPlot <- renderPlot({
output
# generate random sample of size n
<- rnorm(input$n)
x
# 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
<- fluidPage(
ui
# 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
<- function(input, output) {
server
$distPlot <- renderPlot({
output
# generate random sample of size n
<- rnorm(input$n)
x
# 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.
::runApp('app', display.mode = 'showcase') shiny
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.
<- function(n){
my_func
<- rnorm(n)
x 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.
<- function(n){
my_func
browser()
<- rnorm(n)
x hist(x)
}my_func(n = 100)
Two things happen when the code is run:
The source code now highlights the
browser()
function with a green arrow. This indicates your current location inside the browser.The console will show that you’re in the browser.
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
<- function(input, output) {
server
$distPlot <- renderPlot({
outputbrowser()
# generate random sample of size n
<- rnorm(input$n)
x
# 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.
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 ----
<- epcdata |>
d 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 ----
<- unique(d$station)
stations <- unique(d$indicator)
indicators <- d |>
locations select(station, lon, lat) |>
unique()
# ui.R ----
<- fluidPage(
ui wellPanel(
h2("Water Quality"),
selectInput("sel_sta", "Station", choices = stations),
selectInput("sel_ind", "Indicator", choices = indicators),
plotlyOutput("tsplot"),
leafletOutput("map") )
)
# server.R ----
<- function(input, output, session) {
server
# * get_data(): reactive to inputs ----
<- reactive({
get_data |>
d filter(
== input$sel_sta,
station == input$sel_ind)
indicator
})
# * tsplot: time series plot ----
$tsplot <- renderPlotly({
output<- ggplot(
g get_data(),
aes(
x = SampleTime,
y = value) ) +
geom_line() +
labs(y = input$sel_ind)
ggplotly(g)
})
# * map ----
$map <- renderLeaflet({
output
# filter locations by station
<- locations |>
locs_sta filter(
== input$sel_sta)
station
# 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 :
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:
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).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. Thedplyr
package is the ‘swiss army knife’ (or ‘plyers’) for data wrangling, along with its close cousintidyr
. 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.Filter
Reduce rows based on condition(s) that evaluate logically (i.e. True or False)|> d ::filter(station == 8) dplyr
Select
Reduce columns to only those specified|> d ::select( dplyrstation = epchc_station, SampleTime,lon = Longitude, lat = Latitude)
Pivot
Transform the data from wide to long format|> d ::pivot_longer( tidyrnames_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 ----
<- epcdata |>
d 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:
- Stations
List of unique station numbers for selecting from a drop-down menu. - Indicators
List of unique indicators for selecting from a drop-down menu. - Locations
List of unique station locations (i.e. stations with lat/lon coordinates) to display on the map.
# * data for select ----
<- unique(d$station)
stations <- unique(d$indicator)
indicators <- d |>
locations 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 ----
<- fluidPage(
ui
# * 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 ----
<- function(input, output, session) {
server
# * get_data(): reactive to inputs ----
<- reactive({
get_data |>
d filter(
== input$sel_sta,
station == input$sel_ind)
indicator
})
# * tsplot: time series plot ----
$tsplot <- renderPlotly({
output<- ggplot(
g 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 ----
$map <- renderLeaflet({
output
# filter locations by station
<- locations |>
locs_sta filter(
== input$sel_sta)
station
# 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.