Lab 2 · Shiny fundamentals

Author

Workshop participant

Goal

Build a small but complete Shiny app from scratch: UI + server, an input wired to an output, a reactive expression, and (stretch) an uploaded file. You will reconstruct, step by step, the example-app/app.R used later for Shinylive.

Recommended setup: create a folder myapp/ and inside it a file app.R that you fill in at each step. Run it with the Run App button (RStudio / Positron), or shiny::runApp("myapp").

Load the penguins data from the palmerpenguins package (palmerpenguins::penguins); there is no bundled CSV file. If you do not have the package, install it with install.packages("palmerpenguins").


1. The skeleton (UI + server)

Every Shiny app is two pieces. Start here and run it.

library(shiny)

ui <- fluidPage(
  titlePanel("My first app")
)

server <- function(input, output, session) {}

shinyApp(ui, server)

You should see an empty page with a title. Nothing reacts yet, because there is no input or output.

2. Add an input and a text output

Add a slider and a text output, and wire them in the server. To do that, replace the ui and server with the following:

ui <- fluidPage(
  titlePanel("My first app"),
  sliderInput("bins", "Bins", 5, 40, 20),
  textOutput("msg")
)

server <- function(input, output, session) {
  output$msg <- renderText({
    paste("You chose", input$bins, "bins")
  })
}

Drag the slider. The text updates. Notice: input$bins reads the slider, output$msg supplies the text, and renderText() wraps the R value.

3. Turn it into a plot

Instead of producing text with renderText() as output$msg, produce a histogram with renderPlot() as output$hist using the following code:

penguins <- palmerpenguins::penguins

penguins |>
  ggplot(aes(body_mass_g)) +
  geom_histogram(
    bins = input$bins, 
    fill = "#2E7D6B", 
    color = "white"
  )

Do not forget to pre-load the {ggplot2} package.

This effectively uses the palmerpenguins dataset, and lets the slider control the number of bins. Then, update the UI to show the plot instead of the text using plotOutput().

4. Add a second decoration

Add a selectInput() for the variable to plot to choose among "body_mass_g" or "bill_length_mm", define it with id variable and use input$variable inside aes(.data[[...]]) to use the selected variable, so the plot reacts to the choice. Run it.

5. Stretch: update the list of variables

Update the selectInput() choices to reflect the available variables in the dataset. This should happen in the server, where the dataset is read. Use names(penguins) to get the variable names, filter them to keep only numeric variables, and update the selectInput() choices with updateSelectInput(). Use req(input$variable) inside renderPlot() to ensure that a variable is selected before plotting.

6. Use a reactive expression

Reading the data inside renderPlot() re-reads the file every time. Move it into a reactive() so it is read once and reused:

server <- function(input, output, session) {
  penguins <- palmerpenguins::penguins

  df <- reactive(penguins)

  output$hist <- renderPlot({
    req(input$variable)
    df() |>
      ggplot(aes(.data[[input$variable]])) +
      geom_histogram(bins = input$bins, fill = "#2E7D6B", color = "white")
  })
}

Try deleting the reactive() and reading inside renderPlot(): it still works, but is slower and less clean. reactive() centralises the “source of the data”.

7. Stretch: uploaded data

Hook up an optional uploaded file that replaces the bundled data:

```r
fileInput("data", "Upload your own CSV (optional)", accept = ".csv")
```

In the server:

df <- reactive({
  if (is.null(input$data)) {
    penguins
  } else {
    readr::read_csv(input$data$datapath, show_col_types = FALSE)
  }
})

Your app now reads a file only once the user provides one. Neither the uploaded file nor the bundled data ever leave the app: this is the property that matters for Shinylive later.

8. Compare with the reference

Open example-app/app.R. Yours should look very similar. Keep your version, as it does not need to match exactly. If you are stuck, copy the reference and read through it. The full reference app is embedded below so this lab stays in sync with the source:

# Palmer penguins explorer
#
# A small, dependency-light Shiny app used in the workshop.
# It works both as a normal Shiny app (Run App) and exported with
# shinylive::export() to run in the browser.
#
# The penguins dataset comes from the palmerpenguins package.

library(shiny)
library(ggplot2)
library(palmerpenguins)

# palmerpenguins bundles and loads the penguins dataset.
penguins <- palmerpenguins::penguins

format_variable_unit <- function(var_names, unit_mapping) {
  sapply(var_names, function(x) {
    # Find if any unit key matches the end of the variable name
    matched_key <- names(unit_mapping)[sapply(names(unit_mapping), function(k) grepl(paste0(k, "$"), x))]
    
    if (length(matched_key) > 0) {
      # Order matches by length descending to match the most specific unit first (e.g., 'mm' before 'm')
      matched_key <- matched_key[order(nchar(matched_key), decreasing = TRUE)][1]
      
      # Clean the prefix by removing the matched unit and any trailing separators (_, -, or camelCase boundaries)
      clean_name <- sub(paste0("([_-]?|(?<=[a-z])(?=[A-Z]))", matched_key, "$"), "", x, perl = TRUE)
    } else {
      clean_name <- x
    }

    # Add spaces back if it was camelCase (optional, e.g., 'maxLength' -> 'max Length')
    clean_name <- gsub("(?<=[a-z])(?=[A-Z])", " ", clean_name, perl = TRUE)
    clean_name <- gsub("[_-]", " ", clean_name) # Replace separators with spaces
    clean_name <- trimws(clean_name)            # Clean extra spaces
    clean_name <- tools::toTitleCase(clean_name)

    if (length(matched_key) > 0) {
      return(paste0(clean_name, " (", unit_mapping[[matched_key]], ")"))
    }
    
    # Return original if no unit match is found
    return(clean_name)
  }, USE.NAMES = FALSE)
}

# 1. Define your unit dictionary
my_units <- list(
  "mm" = "mm",
  "g" = "g"
)

ui <- fluidPage(
  titlePanel("Palmer penguins explorer"),

  sidebarLayout(
    sidebarPanel(
      selectInput(
        "variable", "Numeric variable",
        choices = c()
      ),
      sliderInput("bins", "Number of bins", min = 5, max = 40, value = 20),
      fileInput("data", "Upload your own CSV (optional)", accept = ".csv")
    ),
    mainPanel(
      plotOutput("hist"),
      tableOutput("summary")
    )
  )
)

server <- function(input, output, session) {
  # Use the uploaded file if provided, otherwise the bundled dataset.
  df <- reactive({
    if (is.null(input$data)) {
      penguins
    } else {
      readr::read_csv(input$data$datapath, show_col_types = FALSE)
    }
  })

  observe({
    bare_vars <- names(df())[sapply(df(), is.numeric)]
    names(bare_vars) <- format_variable_unit(bare_vars, my_units)
    updateSelectInput(
      session = session, 
      inputId = "variable", 
      choices = bare_vars
    )
  })

  output$hist <- renderPlot({
    req(input$variable)
    df() |>
      na.omit() |> 
      ggplot(aes(.data[[input$variable]])) +
      geom_histogram(
        bins = input$bins, 
        fill = "#2E7D6B", 
        color = "white"
      ) +
      labs(
        x = format_variable_unit(input$variable, unit_mapping = my_units), 
        y = "Count", 
        title = "Histogram"
      )
  })

  output$summary <- renderTable({
    req(input$variable)
    df() |>
      dplyr::summarise(
        n    = dplyr::n(),
        mean = mean(.data[[input$variable]], na.rm = TRUE),
        sd   =   sd(.data[[input$variable]], na.rm = TRUE),
        min  =  min(.data[[input$variable]], na.rm = TRUE),
        max  =  max(.data[[input$variable]], na.rm = TRUE)
      )
  })
}

shinyApp(ui, server)

9. Conclusion

You are done with Lab 2. Move to lab-03-shinylive.qmd. You will export this very app to a static site, so it helps if the app currently runs.