Lab 28: Building Interactive Web Apps with Shiny

BI 255 · Bethune-Cookman University · Fall 2026

Author

Dr. Rosie Stanbrook-Buyer

Published

November 4, 2026


From Analysis to Application

Every analysis you have built so far produces output you can see — but only you can see it. Shiny allows you to turn any R analysis into an interactive web application that anyone can use, without needing to know R. A slider or drop-down menu replaces lines of code.

This lab builds three Shiny apps of increasing complexity, all with biological themes. By the end you will be able to share your data science work with collaborators, clinicians, or the public as a working tool rather than a static report.

NoteLearning Objectives

By the end of this lab you will be able to:

  1. Explain the structure of a Shiny app: ui (user interface) and server
  2. Add interactive input widgets: sliderInput, selectInput, numericInput, checkboxInput
  3. Produce reactive outputs: plotOutput, tableOutput, textOutput
  4. Understand what reactive() and render*() functions do
  5. Build and run a Shiny app locally in Posit Cloud
  6. Share a Shiny app via shinyapps.io

Part 1: The Anatomy of a Shiny App

Every Shiny app has exactly two components:


STRUCTURE OF A SHINY APP
=========================

library(shiny)

ui <- fluidPage(
  # --- What the USER sees ---
  # Layout, input widgets, and output placeholders go here
)

server <- function(input, output) {
  # --- What R does in the BACKGROUND ---
  # Reactive computations and rendered outputs go here
}

shinyApp(ui = ui, server = server)
ImportantThe Key Concept: Reactivity

In a normal R script, code runs from top to bottom once. In Shiny, code reacts — when a user moves a slider, only the parts of the server that depend on that slider re-run automatically.

The connection between input and output is maintained by reactive expressions. Think of it as a spreadsheet: when you change a cell, everything that depends on it updates instantly.


Part 2: App 1 — Normal Distribution Explorer

This app lets students explore how the mean and standard deviation shape the normal distribution — ideal for understanding the concepts from Labs 15–16.

Create a new file in Posit Cloud: File > New File > Shiny Web App, then paste this code:

library(shiny)
library(ggplot2)

ui <- fluidPage(
  titlePanel("Normal Distribution Explorer"),

  sidebarLayout(
    sidebarPanel(
      sliderInput("mean",
                  label = "Mean (mu)",
                  min   = -10, max = 10,
                  value = 0,   step = 0.5),

      sliderInput("sd",
                  label = "Standard Deviation (sigma)",
                  min   = 0.5, max = 5,
                  value = 1,   step = 0.1),

      numericInput("n_samples",
                   label = "Number of random samples to overlay",
                   value = 200, min = 50, max = 2000, step = 50),

      checkboxInput("show_68_rule",
                    label = "Show 68-95-99.7 rule shading",
                    value = TRUE)
    ),

    mainPanel(
      plotOutput("dist_plot", height = "450px"),
      hr(),
      verbatimTextOutput("summary_stats")
    )
  )
)

server <- function(input, output) {

  # Reactive: generate samples whenever inputs change
  samples <- reactive({
    rnorm(input$n_samples, mean = input$mean, sd = input$sd)
  })

  output$dist_plot <- renderPlot({
    mu  <- input$mean
    sig <- input$sd
    x   <- seq(mu - 4 * sig, mu + 4 * sig, length.out = 400)
    df  <- data.frame(x = x, y = dnorm(x, mu, sig))

    p <- ggplot(df, aes(x = x, y = y)) +
      geom_histogram(data     = data.frame(x = samples()),
                     aes(x = x, y = after_stat(density)),
                     binwidth  = sig / 4,
                     fill      = "#A8C4D9",
                     colour    = "white",
                     alpha     = 0.6,
                     inherit.aes = FALSE) +
      geom_line(colour = "#2C5F8A", linewidth = 1.4)

    if (input$show_68_rule) {
      p <- p +
        geom_area(data = subset(df, x >= mu - sig & x <= mu + sig),
                  fill = "#2C5F8A", alpha = 0.2) +
        geom_area(data = subset(df, x >= mu - 2*sig & x <= mu + 2*sig),
                  fill = "#2C5F8A", alpha = 0.12) +
        annotate("text", x = mu, y = dnorm(mu, mu, sig) * 0.5,
                 label = "68%", size = 5, colour = "#2C5F8A")
    }

    p + labs(title = paste0("N(", mu, ", ", sig, ")"),
             x = "Value", y = "Density") +
      theme_classic(base_size = 14)
  })

  output$summary_stats <- renderPrint({
    s <- samples()
    cat("Theoretical mean:", input$mean, " | Sample mean:", round(mean(s), 3), "\n")
    cat("Theoretical SD:  ", input$sd,   " | Sample SD:  ", round(sd(s),   3), "\n")
  })
}

shinyApp(ui = ui, server = server)
TipRunning Your App

In Posit Cloud, save the file as app.R inside its own folder, then click the Run App button at the top of the editor pane. The app opens in a new browser tab. To stop it, press the stop button or press Escape.


Part 3: App 2 — Enzyme Activity Data Explorer

This app lets a user upload any CSV file and interactively explore distributions of any numeric column.

library(shiny)
library(ggplot2)
library(dplyr)

ui <- fluidPage(
  titlePanel("Biological Data Explorer"),

  sidebarLayout(
    sidebarPanel(
      fileInput("csv_file",
                "Upload a CSV file",
                accept = ".csv"),

      uiOutput("column_selector"),   # dynamically generated after upload

      selectInput("plot_type",
                  "Plot type",
                  choices = c("Histogram", "Box Plot", "Density"),
                  selected = "Histogram"),

      sliderInput("bins",
                  "Histogram bins",
                  min = 5, max = 60, value = 20)
    ),

    mainPanel(
      plotOutput("main_plot"),
      tableOutput("summary_table")
    )
  )
)

server <- function(input, output) {

  # Reactive: read uploaded CSV
  uploaded_data <- reactive({
    req(input$csv_file)
    read.csv(input$csv_file$datapath)
  })

  # Dynamically create column selector based on uploaded data
  output$column_selector <- renderUI({
    df  <- uploaded_data()
    num_cols <- names(df)[sapply(df, is.numeric)]
    selectInput("y_column", "Select numeric column to plot", choices = num_cols)
  })

  output$main_plot <- renderPlot({
    req(input$y_column)
    df  <- uploaded_data()
    val <- df[[input$y_column]]

    if (input$plot_type == "Histogram") {
      ggplot(df, aes(x = .data[[input$y_column]])) +
        geom_histogram(bins = input$bins, fill = "#2C5F8A", colour = "white") +
        theme_classic(base_size = 14) +
        labs(title = paste("Distribution of", input$y_column),
             x = input$y_column, y = "Count")
    } else if (input$plot_type == "Density") {
      ggplot(df, aes(x = .data[[input$y_column]])) +
        geom_density(fill = "#2C5F8A", alpha = 0.5) +
        theme_classic(base_size = 14)
    } else {
      ggplot(df, aes(y = .data[[input$y_column]])) +
        geom_boxplot(fill = "#2C5F8A", alpha = 0.7) +
        theme_classic(base_size = 14)
    }
  })

  output$summary_table <- renderTable({
    req(input$y_column)
    df <- uploaded_data()
    val <- df[[input$y_column]]
    data.frame(
      Statistic = c("n", "Mean", "Median", "SD", "Min", "Max"),
      Value     = round(c(length(val), mean(val), median(val),
                          sd(val), min(val), max(val)), 3)
    )
  })
}

shinyApp(ui = ui, server = server)

Part 4: Understanding Reactivity More Deeply

# The key reactive functions:

# reactive()    — creates a reactive expression (cached until inputs change)
#                 Access it like a function: my_data()

# renderPlot()  — renders a ggplot or base R plot to plotOutput("id")
# renderTable() — renders a data frame to tableOutput("id")
# renderPrint() — renders printed text to verbatimTextOutput("id")
# renderText()  — renders a character string to textOutput("id")

# req()         — stops execution silently until the specified input has a value
#                 (essential when waiting for file uploads or user selections)

# isolate()     — reads an input without creating a reactive dependency
#                 (useful when you don't want the output to update on every change)

# observeEvent(input$button, { ... })
#               — runs code ONLY when a specific input changes (e.g., a button click)
NoteCommon Shiny Widgets Reference
Widget function What it creates
sliderInput() A draggable slider for numeric values
selectInput() A dropdown menu
numericInput() A number entry box
textInput() A text entry box
checkboxInput() A single tick box
radioButtons() Mutually exclusive choices
fileInput() A file upload button
actionButton() A click button to trigger an action

Part 5: Publishing Your App

To share your app publicly on shinyapps.io:

# Install rsconnect package
install.packages("rsconnect")

# Set up your account (one-time — sign up at shinyapps.io first)
rsconnect::setAccountInfo(
  name   = "your_username",
  token  = "your_token_here",
  secret = "your_secret_here"
)

# Deploy the app
rsconnect::deployApp(appDir = "path/to/your/app/folder")
Tipshinyapps.io Free Tier

The free tier of shinyapps.io allows: - Up to 5 apps simultaneously - 25 active hours per month - Suitable for class projects and portfolio demonstrations

For a permanent portfolio, consider hosting on a free Shiny server or GitHub Pages with a WebAssembly-based Shiny (shinylive).


3-Minute Knowledge Check

Close your notes. Answer these on your own — you have 3 minutes. We’ll go through the answers together after.

CautionKnowledge Check Questions

1. What are the two main components of every Shiny app, and what is the responsibility of each?

2. In a Shiny app, you add sliderInput("n", "Sample size", min=10, max=500, value=100). How do you access the current value of this slider inside the server function?

3. Why is req() important when building an app that depends on a file upload?

4. You want a plot to update whenever a slider changes, but you also have a checkbox that you do NOT want to trigger an update. Which function would you use for the checkbox?

a) `reactive()`    b) `req()`    c) `isolate()`    d) `renderPlot()`

5. True or False: In Shiny, you can use plotOutput("my_plot") in the ui and renderTable({...}) in the server to display a table — as long as both use the same ID.

1. The two components are: ui (user interface) — defines what the user sees: layout, input widgets (sliders, dropdowns), and output placeholders (where plots or tables will appear); and server — defines what R does in the background: performs calculations and creates outputs using reactive expressions, storing results in output$name that correspond to placeholders in the ui.

2. Inside the server function, access the slider value with input$n. All input values are accessed via input$ followed by the inputId specified as the first argument of the widget (here, "n").

3. req() is essential because when the app first loads, no file has been uploaded yet — input$csv_file is NULL. Without req(), any reactive expression that tries to use the file will error immediately on startup. req(input$csv_file) silently stops execution until the user has actually selected a file, preventing premature errors.

4. c) isolate()isolate(input$my_checkbox) reads the checkbox value without creating a reactive dependency, meaning Shiny will not re-run the output block when the checkbox changes. This is used when you want to read a value at the moment of computation without subscribing to its future changes.

5. False — plotOutput("my_plot") in the ui requires renderPlot({...}) in the server with the same ID "my_plot". You cannot mix output types: renderTable() must be paired with tableOutput(), renderPlot() with plotOutput(), renderText() with textOutput(), and so on. The output type in the ui and the render function in the server must match.


Lab 28 Checklist

Before you leave, make sure you can:

TipBonus Challenge

Build a Shiny app for the Bradford assay from Lab 22. The ui should allow the user to input an observed absorbance value (numericInput). The server should use the linear regression model (which you can hard-code with the intercept and slope from Lab 22) to calculate and display the predicted protein concentration, along with a 95% prediction interval. Display the result with renderText() and also plot the standard curve with the user’s data point highlighted.


Before Next Class (Friday)

  • Friday (Lab 29): Dose-response curves — fitting sigmoidal curves and calculating IC50 values, a core technique in pharmacology and toxicology