Lecture 8: Lab – Shiny (R) – Packaging, Documentation, and Deployment
Jared Andrews
April 16, 2026
Source:vignettes/articles/Lecture08_Shiny_Packaging_Deployment.Rmd
Lecture08_Shiny_Packaging_Deployment.RmdMotivation
A standalone app using your data alone is useful. But packaging the app into your R package makes it easier to maintain, share, and deploy. It also makes it simple to keep the app code aligned with the core package logic.
Why Package Your Shiny App?
Current State: Standalone App
Right now, your Shiny app is a standalone app.R file. To
run it, users need to:
- Clone your repo (or copy the file)
- Install all dependencies manually
- Like change data inputs to point to their own files
- Run
shiny::runApp("path/to/app.R")
Goal: Packaged App
After packaging, users can easily run the app on arbitrary data.
library(ADS8192)
# Run with built-in example data
data("example_se")
run_app(se = example_se)
# Or supply your own SummarizedExperiment
library(airway)
data("airway")
run_app(se = airway)Benefits:
- Single install command handles all dependencies
- Works anywhere — no file paths to manage
- Works on any data — pass any SummarizedExperiment
- Version-controlled — users get consistent behavior
-
Documented —
?run_appshows how to use it, parameters, etc
Part 1: Package Structure for Shiny Apps
Functionizing a Shiny app and adding it to a package requires a few
adjustments to the typical Shiny app structure. The key is to separate
the UI and server logic into functions that can be called from a wrapper
function (e.g., run_app()) that returns the app.
We will also make a tweak to the directory structure to accommodate
deployment platforms that expect a standalone app.R
file.
Step 1: Create App Files in R/
R/app_ui.R
Now create R/app_ui.R with the UI definition. This is a
standard Shiny UI function, but it’s wrapped in a function that can be
called from run_app().
This should largely just require copy-pasting your existing UI code, but it may require adjustments to your server logic to update the available choices for some of the inputs based on the data provided.
#' Shiny App UI
#'
#' @import shiny
#' @importFrom bslib page_sidebar sidebar navset_card_tab nav_panel bs_theme
#' @return A Shiny UI definition
#' @noRd
app_ui <- function() {
page_sidebar(
title = "ADS 8192 PCA Explorer",
theme = bs_theme(bootswatch = "flatly"),
sidebar = sidebar(
h4(icon("cogs"), "Analysis Settings"),
selectInput("assay_name", "Assay:", choices = NULL),
numericInput(
"n_top",
"Top variable genes:",
value = 500, min = 5, step = 50
),
checkboxInput("log_transform", "Log-transform counts", TRUE),
checkboxInput("scale", "Scale features", TRUE),
hr(),
h4(icon("palette"), "Visualization"),
selectInput("color_by", "Color by:", choices = NULL),
selectInput("shape_by", "Shape by:", choices = NULL),
fluidRow(
column(
6,
numericInput("pc_x", "PC X:",
value = 1,
min = 1, max = 8
)
),
column(
6,
numericInput("pc_y", "PC Y:",
value = 2,
min = 1, max = 8
)
)
),
sliderInput("point_size", "Point size:",
value = 4,
min = 1, max = 10, step = 1
),
hr(),
downloadButton("download_plot", "Download Plot")
),
navset_card_tab(
nav_panel(
"PCA Plot",
plotOutput("pca_plot", height = "500px")
),
nav_panel(
"Variance",
plotOutput("variance_plot", height = "400px")
),
nav_panel(
"Sample Data",
DT::dataTableOutput("scores_table")
)
)
)
}R/app_server.R
Now create R/app_server.R with the server logic. This
function takes the Shiny input, output, and
session objects, as well as the
SummarizedExperiment data, and contains all the reactive
logic for the app.
R/app_server.R (click to expand)
#' Shiny App Server
#'
#' @param input Shiny input
#' @param output Shiny output
#' @param session Shiny session
#' @param se A \code{SummarizedExperiment} object
#'
#' @return NULL (side effects only)
#' @noRd
#'
#' @import shiny
#' @importFrom SummarizedExperiment colData assayNames
#' @importFrom ggplot2 ggsave
#' @author Jared Andrews
app_server <- function(input, output, session, se) {
se_data <- reactiveVal(se)
# Update select inputs based on available metadata and assays
observe({
se <- se_data()
req(se)
cols <- colnames(colData(se))
updateSelectInput(session, "color_by", choices = cols)
updateSelectInput(session, "shape_by",
choices = c("None", cols))
updateSelectInput(session, "assay_name",
choices = assayNames(se))
updateNumericInput(session, "n_top",
max = nrow(se))
updateNumericInput(session, "pc_x", max = ncol(se))
updateNumericInput(session, "pc_y", max = ncol(se))
})
# Compute PCA (cached; only re-runs when analysis params change)
pca_result <- reactive({
req(se_data(), input$n_top, input$assay_name)
validate(
need(input$n_top >= 10,
"Please select at least 10 genes"),
need(input$n_top <= nrow(se_data()),
"Cannot select more genes than available")
)
run_pca(
se_data(),
assay_name = input$assay_name,
n_top = input$n_top,
log_transform = input$log_transform,
scale = input$scale
)
})
output$pca_plot <- renderPlot({
req(pca_result(), input$color_by)
n_pcs <- ncol(pca_result()$pca$x)
validate(
need(input$pc_x <= n_pcs,
paste("PC X must be <=", n_pcs)),
need(input$pc_y <= n_pcs,
paste("PC Y must be <=", n_pcs)),
need(input$pc_x != input$pc_y,
"Please select different PCs for X and Y"),
need(input$point_size > 0,
"Point size must be positive")
)
shape <- if (is.null(input$shape_by) || input$shape_by == "None") {
NULL
} else {
input$shape_by
}
plot_pca(
pca_result(),
color_by = input$color_by,
shape_by = shape,
pcs = c(input$pc_x, input$pc_y),
point_size = input$point_size
)
})
output$variance_plot <- renderPlot({
req(pca_result())
plot_variance_explained(pca_result())
})
output$scores_table <- DT::renderDataTable({
req(pca_result())
DT::datatable(
pca_result()$scores,
options = list(pageLength = 10, scrollX = TRUE)
)
})
output$download_plot <- downloadHandler(
filename = function() {
paste0("pca_plot_", Sys.Date(), ".png")
},
content = function(file) {
shape <- if (is.null(input$shape_by) ||
input$shape_by == "None") {
NULL
} else {
input$shape_by
}
p <- plot_pca(
pca_result(),
color_by = input$color_by,
shape_by = shape,
point_size = input$point_size
)
ggsave(file, p, width = 8, height = 6, dpi = 300)
}
)
}R/run_app.R
R/run_app.R (click to expand)
#' Run the PCA Explorer Shiny Application
#'
#' Launches an interactive Shiny application for exploring PCA results
#' on SummarizedExperiment data. The app allows users to select assays, adjust PCA parameters,
#' and visualize results with customizable options.
#'
#' @param se A \code{\link[SummarizedExperiment]{SummarizedExperiment}} object
#' to explore.
#' @param return_as_list If \code{TRUE}, returns a list containing the UI and
#' server functions instead of launching the app. Useful for certain deployment
#' scenarios.
#' @param ... Additional arguments passed to \code{\link[shiny]{shinyApp}()}.
#'
#' @return A Shiny app object or a named list containing the UI and
#' server functions if \code{return_as_list = TRUE}.
#'
#' @import shiny
#' @importFrom methods is
#' @export
#' @author Jared Andrews
#'
#' @examples
#' if (interactive()) {
#' library(ADS8192)
#' data("example_se")
#' run_app(se = example_se)
#' }
run_app <- function(se, return_as_list = FALSE, ...) {
if (!is(se, "SummarizedExperiment")) {
stop("'se' must be a SummarizedExperiment object.", call. = FALSE)
}
server <- function(input, output, session) {
app_server(input, output, session, se = se)
}
app <- shiny::shinyApp(
ui = app_ui(),
server = server,
...
)
if (return_as_list) {
return(list(ui = app_ui(), server = server))
} else {
app
}
}At this point, you should be able to run your app using this function, and it should work with arbitrary data:
library(ADS8192)
data("example_se")
run_app(se = example_se)
# Should also work with other SummarizedExperiment objects
library(airway)
data("airway")
run_app(se = airway)Part 2: Dependencies for the App
Updating DESCRIPTION
The Shiny app needs additional packages. If we wanted to, we could
make these packages optional by putting them in Suggests
instead of Imports. This way, users who only want the R API
don’t need to install Shiny and its dependencies. If we did that, we’d
also need to check for those packages at runtime in
run_app() and give a helpful error message if they’re
missing.
library(usethis)
# Shiny app dependencies
use_package("shiny")
use_package("bslib")
use_package("DT")Exercise A: Ensure
run_app()works after installing from GitHub (no local relative paths). Test in a fresh R session.
Part 3: Documentation
Part 4: Testing Shiny Apps
The Challenge
Testing Shiny apps is harder than testing regular functions:
- Apps are stateful (inputs persist)
- Outputs depend on UI interactions
- Need a running R process
Testing Strategy Options
1. Test the Logic, Not the App
The simplest approach is to keep logic in testable functions and test those:
2. Smoke Tests with shinytest2
For more thorough testing, you can use shinytest2 to simulate user interactions and verify outputs. This is more complex to set up and maintain, but it can catch issues that unit tests miss, particularly when you have complex reactive logic.
Part 5: Deployment (Optional)
Allowing users to run your app locally is fine, but sometimes you may want to deploy the application with your own data for a collaborator to use, to serve as a companion app for a publication, or to serve as an example to show off its functionality without requiring users to install R or the package.
In our case, we want want an example of our app so that potential users can test it out, for which we can use the example data.
Note, you do not need to deploy your app for HW1. This is an optional step that can be done after the assignment is submitted (or not at all), but it is worth knowing about this aspect of Shiny development.
Deployment Options
| Option | Audience | Cost | Infrastructure |
|---|---|---|---|
| Posit Connect | Internal/institutional | Subscription | Posit-managed or self-hosted |
| Self-hosted Shiny Server | Any | Free (open source) | Your own server |
| Posit Connect Cloud | Any | Free tier available | Posit-managed |
We’ll be deploying to Posit Connect Cloud, which is a simple way to deploy a Shiny application directly from a Github repository. It handles all the infrastructure and scaling for you, and it integrates well with R packages.
It also has a free tier that is sufficient for lightweight apps.
Deployment Steps
- Sign in to Posit Connect Cloud with your Github
- Push your package to GitHub (make sure
run_app()works without local paths) - Install your package in a fresh session locally,
e.g.
remotes::install_github("St-Jude-MS-ABDS/ADS8192")and verifyrun_app()works. - Create an
app.rfile ininst/app/that callsrun_app(return_as_list = TRUE)and passes the example data:
library(ADS8192)
data("example_se")
app <- run_app(se = example_se, return_as_list = TRUE)
shiny::shinyApp(ui = app$ui, server = app$server)Using return_as_list = TRUE allows us to return the UI
and server functions without launching the app, which is necessary for
deployment platforms that expect a standalone app.R
file.
- Create a
manifest.jsonfile ininst/app/that specifies the dependencies for the app. This is required for Posit Connect Cloud to know which packages to install.
This can be generated easily with the rsconnect
package:
- Push the manifest and app files to GitHub.
- Deploy to Posit Connect Cloud, pointing to the
inst/app/app.Rfile as the entry point.
Summary
This lab we:
- Packaged the Shiny app with proper structure (
R/for logic,inst/app/for deployed example) - Added
run_app()as a documented, exported function - Discussed testing strategies
- Discussed app deployment options
Complete File Reference
Final Directory Structure
ADS8192/
├── DESCRIPTION
├── NAMESPACE
├── LICENSE.md
├── README.Rmd
├── README.md
├── R/
│ ├── data.R
│ ├── pca.R
│ ├── plotting.R
│ ├── export.R
│ ├── app_ui.R
│ ├── app_server.R
│ └── run_app.R
├── inst/
│ └── app/
│ └── app.R
├── man/
│ ├── run_app.Rd
│ └── ...
├── tests/
│ ├── testthat/
│ │ └── ...
│ └── testthat.R
├── vignettes/
│ ├── getting-started.Rmd
│ └── shiny-app.Rmd
└── _pkgdown.yml
Session Info
## R version 4.5.3 (2026-03-11)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.4 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
## [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
## [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
## [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
##
## time zone: UTC
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## loaded via a namespace (and not attached):
## [1] digest_0.6.39 desc_1.4.3 R6_2.6.1 fastmap_1.2.0
## [5] xfun_0.57 cachem_1.1.0 knitr_1.51 htmltools_0.5.9
## [9] rmarkdown_2.31 lifecycle_1.0.5 cli_3.6.6 sass_0.4.10
## [13] pkgdown_2.2.0 textshaping_1.0.5 jquerylib_0.1.4 systemfonts_1.3.2
## [17] compiler_4.5.3 tools_4.5.3 ragg_1.5.2 bslib_0.10.0
## [21] evaluate_1.0.5 yaml_2.3.12 otel_0.2.0 jsonlite_2.0.0
## [25] rlang_1.2.0 fs_2.1.0 htmlwidgets_1.6.4