install.packages("shiny") # once per computer
library(shiny) # every session37 Shiny Data Viz
How to create a Shiny application in R
This chapter turns a plot into an interactive web page. Researchers learn what the shiny package does, how to start a Shiny app in RStudio, and how an app is built from two halves that talk to each other: a user interface that offers choices (dropdowns, sliders) and a server that redraws the plot whenever a choice changes. The worked example is the Cohort 10 health status dataset from Visualize a Relationship: the app lets a viewer pick the grouping variable, set an age range, and see the scatterplot and trend lines update. The chapter then shows the two upgrades most students want (plotly for hover-and-zoom plots, DT for searchable tables), how to publish an app to shinyapps.io so a link can go on a poster, and where to keep learning.
shiny, interactive, plotly, DT, shinyapps.io, reactive
- Shiny Fundamentals with R (DataCamp skill track; four short courses, the first is the one to do before this chapter)
- Mastering Shiny, Hadley Wickham’s free book (Chapters 1–3 cover everything used here)
- Shiny for R (Posit’s lessons) and the Shiny cheat sheet (PDF)
- plotly for R and
ggplotly() - DT: interactive tables
- Shiny in a Quarto document (how the original FRI slides ran the app inside the slides)
- Zach’s apps: Health Status Explorer (embedded below) and Plot Comparison (the same plots and tables, static and interactive, side by side)
- Zach’s original slides (Google Slides)
37.1 Try one first
This is the Cohort 10 health status dataset from Visualize a Relationship, as a Shiny app Zach built. Change an input and watch the plot redraw. The rest of the chapter explains how an app like this is put together and how to make your own.
If the frame is blank, the app is waking up (free shinyapps.io apps sleep when idle); give it 20 seconds or open it in its own tab.
A Shiny app needs a live R session behind it. The app above is running on Posit’s shinyapps.io server and this page only shows it in a frame; the playbook itself is a static web page, so none of the code chunks below run when the page is built. Copy them into RStudio.
37.2 What is Shiny?
Every plot you have made so far is a picture: the reader sees what you chose to show. Shiny is an R package that turns R code into an interactive web page, so that the reader can choose. A viewer of a Shiny app picks a variable from a dropdown, drags a slider, or clicks a group, and the plot redraws in front of them. The R code is still your ggplot2 code; Shiny wraps it in a page and runs it again every time an input changes.
This is an Advanced Play. Nothing in your RD Report or Final Report requires an app. It is here because an app on a poster’s QR code lets a reader explore the finding you are presenting, and because building one teaches you how your code is organized.
37.3 Install Shiny
Shiny is not part of base R. Install it once, then load it in every session like any package (R Packages).
37.4 Start an app in RStudio
In RStudio, go to File > New File > Shiny Web App…. Give the app a name, choose Single File (app.R), and pick where it lives: a folder inside your Lastname_FRI project. A single file is easier to keep track of than separate ui.R and server.R files, and it is the form this chapter uses.
RStudio opens an app.R that already runs: a histogram of waiting times at Old Faithful with a slider for the number of bins. Click Run App (top right of the editor) and move the slider. That is the whole idea in one picture. Click the stop sign in the Console when you are done.
37.5 The three parts of every app
An app.R file has three parts, always in this order.
library(shiny)
ui <- fluidPage( # 1. the USER INTERFACE: what the viewer sees and can change
# inputs and outputs go here
)
server <- function(input, output, session) { # 2. the SERVER: the R code that reacts to the inputs
# code that makes the outputs goes here
}
shinyApp(ui, server) # 3. put the two together and run- The UI is the page: a title, the controls the viewer can use (inputs), and the places where results appear (outputs). Each input and each output has a name in quotes (
"xvar","plot1"). - The server is where your R code lives. It reads what the viewer chose through
input$nameand fills the outputs throughoutput$name. shinyApp(ui, server)starts the app. In a single-file app this line is required; without it nothing runs.
The link between the two halves is the name. If the UI has selectInput("xvar", …), the server reads that choice as input$xvar. If the server writes output$plot1 <- renderPlot({ … }), the UI shows it with plotOutput("plot1").
Code inside renderPlot({ … }), renderTable({ … }), or reactive({ … }) is reactive: Shiny runs it when the app starts and runs it again every time an input it uses changes. Code outside those braces, at the top of app.R, runs once when the app starts. So load packages and read data at the top; put anything that depends on a viewer’s choice inside a render*() or reactive().
37.6 📋 The Play: the health status scatterplot as an app
Visualize a Relationship drew self-rated health against subjective social status for respondents 25 and older, with a trend line per racialized-identity group. The app below lets the viewer choose the grouping variable (minoritized status or sex), set the age range with a slider, and decide whether to show the trend lines. It uses health_status_data.csv from that chapter; copy the file into the app’s folder.
health_status_data.csv (the Cohort 10 class dataset)
Every FRI Public Health team in Cohort 10 asked the same self-rated health question, Would you say your health in general is excellent, very good, good, fair, or poor?, along with the class variables of that year (age, sex, racialized identity, perceived income, and the MacArthur ladder of subjective social status). The five team datasets were merged into one file of 343 respondents so that health status could be examined across the whole class. SAMPLE_ID says which team’s study a person came from.
This is real data, already cleaned and de-identified: no response IDs, passwords, dates, or open-ended answers, and the five team samples differ in whom they recruited (students, families, farmers-market visitors), which is why the chapters filter by age. The variable names predate the class naming rules, so the Play renames them: HEALTH_STATUS becomes HEALTHSTATUS (1 = Poor … 5 = Excellent); SSS is subjective social status (1 to 10); SEX is biological sex (0 = male, 1 = female); and RACIALIZED_IDENTITY (1 = identified as white only, 0 = any other identity) becomes MINORITIZED_01 (1 = minoritized, 0 = not), named for the 1 the way a _01 variable should be. The other columns (RG_…, RI_…, PoorFairHealth, ExcellentHealth) are earlier recodes of the same questions and are not used here.
37.6.1 Step 1: Packages and data, once, at the top
library(shiny)
library(tidyverse)
healthstatusdata <- read.csv("health_status_data.csv") |> # the file sits next to app.R
rename(HEALTHSTATUS = HEALTH_STATUS) |>
mutate(
MINORITIZED = factor(if_else(RACIALIZED_IDENTITY == 1, 0, 1),
levels = c(0, 1), labels = c("Not minoritized", "Minoritized")),
SEX = factor(SEX, levels = c(0, 1), labels = c("Male", "Female")))
group_choices <- c("Racialized identity" = "MINORITIZED", # what the viewer sees = the column name
"Sex" = "SEX")This is the same cleaning as the chapter, and it runs once. group_choices is a named vector: the names are what the dropdown shows, the values are the column names the server will use.
37.6.2 Step 2: The UI
ui <- fluidPage(
titlePanel("Self-rated health and subjective social status"),
sidebarLayout(
sidebarPanel(
selectInput("groupvar", "Color the points by:", choices = group_choices),
sliderInput("age", "Age range:", min = 18, max = 77, value = c(25, 77)),
checkboxInput("lines", "Show trend lines", value = TRUE),
textOutput("n_text") # "n = 122 respondents"
),
mainPanel(
plotOutput("scatter", height = "500px")
)
)
)Three inputs (groupvar, age, lines) and two outputs (n_text, scatter). sidebarLayout() puts the controls on the left and the plot on the right, the layout most apps use.
37.6.3 Step 3: The server
server <- function(input, output, session) {
# the data the viewer asked for: recomputed whenever the slider moves
filtered <- reactive({
healthstatusdata |>
filter(AGE >= input$age[1], AGE <= input$age[2])
})
output$n_text <- renderText({
paste0("n = ", nrow(filtered()), " respondents")
})
output$scatter <- renderPlot({
p <- ggplot(filtered(), aes(x = SSS, y = HEALTHSTATUS, color = .data[[input$groupvar]])) +
geom_jitter(width = 0.15, height = 0.12, size = 2.2, alpha = 0.7) +
scale_x_continuous(breaks = 1:10, limits = c(1, 10)) +
scale_y_continuous(breaks = 1:5, limits = c(0.8, 5.2),
labels = c("Poor", "Fair", "Good", "Very Good", "Excellent")) +
labs(x = "Subjective social status (1 = bottom rung, 10 = top rung)",
y = "Health status", color = names(group_choices)[group_choices == input$groupvar]) +
theme_bw(base_size = 14)
if (input$lines) {
p <- p +
geom_smooth(method = "lm", se = FALSE, linewidth = 1.3) +
geom_smooth(aes(group = 1), method = "lm", se = FALSE,
color = "black", linetype = "dashed", linewidth = 1)
}
p
})
}
shinyApp(ui, server)Three things to notice:
filtered()is a reactive expression. It is defined once, used twice (for the n and for the plot), and both outputs update when the slider moves. Note the parentheses: you call a reactive,filtered(), like a function..data[[input$groupvar]]is how ggplot2 takes a column name that arrives as text.aes(color = input$groupvar)would color every point by the word “SEX”;.data[[ ]]looks the column up by name.- The trend lines are added only
if (input$lines). A plot built in pieces like this (p <- p + …) is ordinary ggplot2; Shiny only decides when to run it.
Save the three steps as one app.R, click Run App, and move the slider to 18. The flat and rising lines from the chapter are the age-25+ picture; with the students back in, both lines flatten, which is the point the chapter’s filter was making.
37.7 Two upgrades most people want
37.7.1 Hover and zoom with plotly
plotly turns a ggplot into a plot the viewer can hover (to read a point’s values), zoom, and pan. Two swaps in the UI and the server and one wrapper around the plot:
library(plotly)
# UI: plotlyOutput("scatter") instead of plotOutput("scatter")
# server: output$scatter <- renderPlotly({ ggplotly(p) }) instead of renderPlot({ p })ggplotly() takes the finished ggplot object (p above) and converts it. Most layers convert as they are; geom_smooth() and custom labels sometimes need a tweak, so look at the result. Install with install.packages("plotly").
37.7.2 Searchable tables with DT
A renderTable() prints a fixed table. DT gives the viewer sorting, searching, and paging:
library(DT)
# UI: DT::dataTableOutput("table1") instead of tableOutput("table1")
# server: output$table1 <- DT::renderDataTable({ filtered() }) instead of renderTable({ filtered() })Zach’s comparison app shows the static and interactive versions of a plot and a table side by side, so you can decide whether the upgrade is worth it for yours.
Zach’s app loads a dozen packages (bslib, shinyWidgets, gghalves, ggdist, shinycssloaders, shinytitle, and more). You do not need any of them to begin. shiny and tidyverse make a working app; add plotly when you want hover; add the rest one at a time when you know what each one is for.
37.9 🏆 Your Turn
library(shiny)
library(tidyverse)
library(readxl)
alldata <- read_excel("SWAPFILE.xlsx") # SWAP: your .cleandata file, copied next to app.R
cleandata <- alldata
cleandata[cleandata == -99] <- NA
cleandata[cleandata == -50] <- NA
# SWAP: the mutate() lines that make your composites and factors (copy them from your report)
ui <- fluidPage(
titlePanel("SWAP: a title a reader understands"),
sidebarLayout(
sidebarPanel(
selectInput("yvar", "Outcome:", choices = c("Well-being" = "WELLBEING", "Stigma" = "STIGMA")), # SWAP: your outcomes
selectInput("groupvar", "Compare by:", choices = c("Gender" = "GENDER")) # SWAP: your grouping variables
),
mainPanel(plotOutput("plot1"))
)
)
server <- function(input, output, session) {
output$plot1 <- renderPlot({
ggplot(cleandata, aes(x = .data[[input$groupvar]], y = .data[[input$yvar]], fill = .data[[input$groupvar]])) +
geom_violin(alpha = 0.6) +
geom_boxplot(width = 0.12, outlier.shape = NA) +
theme_bw(base_size = 14)
})
}
shinyApp(ui, server)| Criteria | Ask yourself |
|---|---|
| Runs | Does Run App open the page with no red text in the Console, and does the plot change when you change the input? |
| One choice at a time | Does each input do one clear thing that a reader would want to try? |
| Same conventions | Are the axes on the full scale, the groups labeled with words, and the colors the ones your team chose? |
| Data | Is the data file next to app.R, and is it data you are allowed to publish? |
| Link | If published, does the link open on a phone, and is it on the poster next to the static version of the plot? |