Inputs and outputs

Lesson 3 — R Shiny

Lesson 3 of 11 Beginner to intermediate ~75 min

Learning objectives

  • Use the full range of built-in inputs and know their return types
  • Match render functions to output functions correctly
  • Update inputs from the server
  • Validate user input and give useful feedback
  • Extend the input set with community packages

Input catalogue

Every input function takes inputId first and label second.

Text and numbers

textInput("name", "Subject ID", value = "", placeholder = "e.g. 001")
textAreaInput("notes", "Comments", rows = 4)
passwordInput("pw", "Password")
numericInput("age", "Age", value = 50, min = 18, max = 100, step = 1)

Returns: character for the text inputs, numeric for numericInput() — and NA when the box is empty, not NULL, which is a common trip-up:

observeEvent(input$age, {
  req(!is.na(input$age))          # NOT req(input$age) — 0 is falsy!
  ...
})

Selection

selectInput("arm", "Treatment arm",
            choices  = c("Placebo", "Drug A", "Drug B"),
            selected = "Placebo",
            multiple = FALSE)

selectInput("params", "Parameters", choices = params, multiple = TRUE)

# Grouped
selectInput("test", "Lab test",
  choices = list(
    Chemistry   = c("ALT", "AST", "BILI"),
    Haematology = c("HGB", "PLAT", "WBC")
  ))

# Named: display label -> returned value
selectInput("arm", "Arm",
            choices = c("Placebo" = "PBO", "Drug A" = "DRGA"))

# Server-side, for thousands of options
selectizeInput("subj", "Subject", choices = NULL)
# then in the server:
updateSelectizeInput(session, "subj", choices = all_ids, server = TRUE)

radioButtons("sex", "Sex", choices = c("M", "F"), inline = TRUE)
checkboxInput("saffl", "Safety population only", value = TRUE)
checkboxGroupInput("flags", "Flags", choices = c("SAFFL", "ITTFL", "PPROTFL"))
TipselectizeInput with server = TRUE

A selectInput with 20,000 subject IDs sends all of them to the browser and makes the page unusable. updateSelectizeInput(..., server = TRUE) keeps them on the server and searches as the user types. Use it above roughly 1,000 choices.

Ranges and dates

sliderInput("age", "Age", min = 18, max = 90, value = 50)
sliderInput("age", "Age range", min = 18, max = 90, value = c(30, 60))
sliderInput("date", "Visit", min = as.Date("2026-01-01"),
            max = as.Date("2026-12-31"), value = as.Date("2026-06-01"))
sliderInput("n", "Animate", 1, 100, 1, animate = TRUE)

dateInput("visit", "Visit date", value = Sys.Date(),
          min = "2026-01-01", format = "yyyy-mm-dd")
dateRangeInput("period", "Period",
               start = "2026-01-01", end = Sys.Date())

dateInput() returns a Date; dateRangeInput() returns a length-2 Date vector.

Actions and files

actionButton("run", "Run analysis", class = "btn-primary",
             icon = icon("play"))
actionLink("more", "Show advanced options")
submitButton("Apply")            # avoid — freezes ALL reactivity until clicked

fileInput("file", "Upload dataset", accept = c(".csv", ".sas7bdat"))
fileInput("files", "Upload several", multiple = TRUE)

Action buttons return an integer that increments on each click, starting at 0. That is why ignoreInit = TRUE matters — without it, observeEvent fires once at startup on the initial 0.

Community inputs

shinyWidgets::pickerInput("arm", "Arm", choices = arms,
                          options = list(`actions-box` = TRUE), multiple = TRUE)
shinyWidgets::switchInput("toggle", "Enable")
shinyWidgets::sliderTextInput("dose", "Dose", choices = c("10mg","20mg","50mg"))
shinyWidgets::airDatepickerInput("dt", "Date", range = TRUE)

colourpicker::colourInput("col", "Colour", value = "#16355e")
shinyFiles::shinyFilesButton("browse", "Browse server files", "Select", FALSE)
shinyTree::shinyTree("tree")

shinyWidgets is worth knowing well — pickerInput with an actions box (select all / deselect all) is a large usability improvement over selectInput(multiple = TRUE).

Output catalogue

Each output function in the UI pairs with a specific render function in the server. Mismatches produce a blank space and no error.

UI Server Renders
textOutput() renderText() Text, pasted together
verbatimTextOutput() renderPrint() Console-style output
plotOutput() renderPlot() Base or ggplot graphics
imageOutput() renderImage() An image file
tableOutput() renderTable() A static HTML table
DT::DTOutput() DT::renderDT() An interactive table
reactable::reactableOutput() reactable::renderReactable() An interactive table
plotly::plotlyOutput() plotly::renderPlotly() An interactive plot
uiOutput() renderUI() Dynamically generated UI
downloadButton() downloadHandler() A file download
# renderText vs renderPrint
output$a <- renderText(c("a", "b", "c"))
#> a b c                      pasted with spaces

output$b <- renderPrint(c("a", "b", "c"))
#> [1] "a" "b" "c"            console representation

output$c <- renderPrint(summary(model))   # this is what renderPrint is for

Plot outputs

ui <- plotOutput("plot",
                 height = "400px",
                 width  = "100%",
                 click  = "plot_click",
                 hover  = "plot_hover",
                 brush  = brushOpts(id = "plot_brush", direction = "x"),
                 dblclick = "plot_dblclick")

server <- function(input, output, session) {
  output$plot <- renderPlot({
    ggplot(d(), aes(AGE, AVAL)) + geom_point()
  }, res = 96)                     # res = 96 makes text sizes match the browser

  output$info <- renderPrint({
    req(input$plot_click)
    nearPoints(d(), input$plot_click, threshold = 10, maxpoints = 1)
  })

  selected <- reactive({
    req(input$plot_brush)
    brushedPoints(d(), input$plot_brush)
  })
}

res = 96 is worth setting on every renderPlot() — without it, text in the plot is systematically too small relative to the page.

Table outputs

output$table <- DT::renderDT({
  DT::datatable(
    filtered(),
    rownames  = FALSE,
    filter    = "top",
    selection = "single",
    extensions = c("Buttons", "Scroller"),
    options = list(
      pageLength = 25,
      scrollX    = TRUE,
      dom        = "Bfrtip",
      buttons    = c("copy", "csv", "excel")
    )
  ) |>
    DT::formatRound(c("AVAL", "CHG"), digits = 2) |>
    DT::formatStyle("CHG",
      backgroundColor = DT::styleInterval(0, c("#fbeaee", "#e6f4ed")))
})

# Which rows are selected?
observeEvent(input$table_rows_selected, {
  row <- filtered()[input$table_rows_selected, ]
  showModal(modalDialog(title = row$USUBJID, renderPrint(row)))
})

DT exposes several inputs automatically: input$table_rows_selected, input$table_rows_all (after filtering), input$table_cell_clicked, input$table_search.

Updating inputs

Every input has an update* counterpart taking session first:

observeEvent(input$study, {
  updateSelectInput(session, "site",
                    choices  = sites_for(input$study),
                    selected = character(0))
})

observeEvent(input$reset, {
  updateSliderInput(session,  "age",   value = c(18, 90))
  updateSelectInput(session,  "arm",   selected = "All")
  updateTextInput(session,    "search", value = "")
  updateCheckboxInput(session,"saffl", value = TRUE)
})

# Change the label and enable/disable
updateActionButton(session, "run", label = "Running...", disabled = TRUE)

# Navigate tabs from the server
updateTabsetPanel(session, "tabs", selected = "results")
nav_select("navset", "results")     # bslib equivalent
WarningCascading updates and infinite loops
observeEvent(input$country, {
  updateSelectInput(session, "site", choices = sites_in(input$country))
})

observeEvent(input$site, {
  updateSelectInput(session, "country", selected = country_of(input$site))
})

These two observers trigger each other indefinitely. Break the cycle by making one direction explicit — usually by driving both from a single reactiveVal holding the canonical selection, and updating the inputs only from that.

Validating input

Three levels, in increasing order of helpfulness:

# 1. Stop silently
output$plot <- renderPlot({
  req(input$file, input$arm)
  ...
})

# 2. Explain why
output$plot <- renderPlot({
  validate(
    need(input$file, "Upload a dataset to begin."),
    need(nrow(filtered()) > 0, "No subjects match these filters.")
  )
  ...
})

# 3. Mark the offending input
library(shinyFeedback)

observeEvent(input$age, {
  bad <- !is.na(input$age) && (input$age < 0 || input$age > 120)
  shinyFeedback::feedbackWarning("age", bad, "Age must be between 0 and 120")
})

shinyFeedback puts the message next to the input that caused it, which is where the user is looking. It requires shinyFeedback::useShinyFeedback() in the UI.

Custom validation with a summary:

validate_form <- function(input) {
  errs <- c()
  if (nchar(input$subject_id) != 10)  errs <- c(errs, "Subject ID must be 10 characters")
  if (is.na(input$age))               errs <- c(errs, "Age is required")
  if (!isTRUE(input$consent))         errs <- c(errs, "Consent must be confirmed")
  if (input$visit_date > Sys.Date())  errs <- c(errs, "Visit date cannot be in the future")
  errs
}

observeEvent(input$submit, {
  errs <- validate_form(input)
  if (length(errs) > 0) {
    showNotification(
      tagList(tags$strong("Please fix the following:"),
              tags$ul(lapply(errs, tags$li))),
      type = "error", duration = NULL
    )
    return()
  }
  save_record(input)
  showNotification("Saved", type = "message")
})

User feedback

# Notifications
showNotification("Analysis complete", type = "message", duration = 5)
showNotification("No data found", type = "warning")
showNotification("Query failed", type = "error", duration = NULL)

id <- showNotification("Working...", duration = NULL, closeButton = FALSE)
on.exit(removeNotification(id), add = TRUE)

# Progress
withProgress(message = "Processing", value = 0, {
  for (i in seq_len(n)) {
    incProgress(1/n, detail = paste("Domain", i, "of", n))
    process(i)
  }
})

# Finer control
progress <- shiny::Progress$new(session, min = 0, max = n)
on.exit(progress$close())
progress$set(message = "Deriving", value = 0)
for (i in seq_len(n)) { progress$inc(1, detail = domains[i]); process(i) }

# Modal dialogs
showModal(modalDialog(
  title = "Confirm deletion",
  "This will permanently remove the dataset. Continue?",
  footer = tagList(
    modalButton("Cancel"),
    actionButton("confirm_delete", "Delete", class = "btn-danger")
  ),
  easyClose = FALSE
))

observeEvent(input$confirm_delete, {
  removeModal()
  delete_dataset()
})

Always use on.exit() with long-running notifications and progress bars — if the computation errors, the spinner must still disappear.

Common mistakes

Mistake Symptom Fix
Mismatched render/output pair Blank space, no error Check the table above
req(input$n) where 0 is valid Silently blocks req(!is.na(input$n))
Large choices in selectInput Page freezes selectizeInput(server = TRUE)
submitButton() Freezes all reactivity actionButton() + bindEvent()
Two-way update* observers Infinite loop Single source of truth
Missing res = 96 in renderPlot Plot text too small Add it
Progress bar left open on error Stuck spinner on.exit(progress$close())
observeEvent on a button without ignoreInit Fires at startup ignoreInit = TRUE

Exercise 3.1 — Cascading filters

Build an app with three dependent dropdowns: Study → Site → Subject. Each level shows only values valid for the level above, and clearing a level clears the ones below it.

Show solution
library(shiny)
library(bslib)
library(dplyr)

# subjects: study_id, site_id, usubjid

ui <- page_sidebar(
  title = "Subject lookup",
  sidebar = sidebar(
    selectInput("study",   "Study",   choices = c("", sort(unique(subjects$study_id)))),
    selectInput("site",    "Site",    choices = ""),
    selectInput("subject", "Subject", choices = "")
  ),
  card(card_header("Selection"), verbatimTextOutput("info"))
)

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

  sites <- reactive({
    req(input$study != "")
    subjects |>
      filter(study_id == input$study) |>
      pull(site_id) |> unique() |> sort()
  })

  subs <- reactive({
    req(input$study != "", input$site != "")
    subjects |>
      filter(study_id == input$study, site_id == input$site) |>
      pull(usubjid) |> sort()
  })

  # Study changed -> repopulate sites, clear subject
  observeEvent(input$study, {
    if (input$study == "") {
      updateSelectInput(session, "site",    choices = "")
      updateSelectInput(session, "subject", choices = "")
    } else {
      updateSelectInput(session, "site",    choices = c("", sites()))
      updateSelectInput(session, "subject", choices = "")
    }
  }, ignoreInit = TRUE)

  # Site changed -> repopulate subjects
  observeEvent(input$site, {
    if (input$site == "") {
      updateSelectInput(session, "subject", choices = "")
    } else {
      updateSelectInput(session, "subject", choices = c("", subs()))
    }
  }, ignoreInit = TRUE)

  output$info <- renderPrint({
    validate(need(input$subject != "", "Select a study, site and subject."))
    subjects |> filter(usubjid == input$subject) |> as.data.frame()
  })
}

Two details that make this work rather than nearly work:

  • The cascade goes one way only. Study updates site and subject; site updates subject; nothing updates upward. That is what prevents the loop described in the callout above.
  • Clearing propagates. Setting input$study back to "" must clear both levels below, otherwise a stale subject selection remains and the downstream filter returns rows from the wrong study.
For thousands of subjects, switch the last dropdown to selectizeInput with updateSelectizeInput(..., server = TRUE).

Exercise 3.2 — A validated data-entry form

Build a form collecting subject ID (exactly 10 characters, format XXX999-9999), age (18–100), sex, visit date (not in the future) and a consent checkbox. Show per-field feedback, disable Submit until everything is valid, and append valid records to a table.

Show solution
library(shiny)
library(bslib)
library(shinyFeedback)
library(dplyr)

ui <- page_sidebar(
  title = "Subject registration",
  useShinyFeedback(),

  sidebar = sidebar(
    width = 340,
    textInput("subject_id", "Subject ID", placeholder = "ABC001-0042"),
    numericInput("age", "Age", value = NA, min = 18, max = 100),
    radioButtons("sex", "Sex", c("Male" = "M", "Female" = "F"), inline = TRUE),
    dateInput("visit_date", "Visit date", value = Sys.Date(), max = Sys.Date()),
    checkboxInput("consent", "Informed consent obtained", FALSE),
    actionButton("submit", "Submit", class = "btn-primary", disabled = TRUE)
  ),

  card(card_header("Registered subjects"), tableOutput("records"))
)

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

  records <- reactiveVal(
    tibble::tibble(subject_id = character(), age = integer(),
                   sex = character(), visit_date = as.Date(character()))
  )

  # --- Per-field validation ------------------------------------------------

  id_ok <- reactive({
    grepl("^[A-Z]{3}\\d{3}-\\d{4}$", input$subject_id %||% "")
  })

  observeEvent(input$subject_id, {
    feedbackWarning("subject_id",
                    nchar(input$subject_id) > 0 && !id_ok(),
                    "Format must be ABC001-0042")
  }, ignoreInit = TRUE)

  age_ok <- reactive({
    !is.na(input$age) && input$age >= 18 && input$age <= 100
  })

  observeEvent(input$age, {
    feedbackWarning("age", !is.na(input$age) && !age_ok(),
                    "Age must be between 18 and 100")
  }, ignoreInit = TRUE)

  duplicate <- reactive({
    id_ok() && input$subject_id %in% records()$subject_id
  })

  observeEvent(list(input$subject_id, records()), {
    feedbackDanger("subject_id", duplicate(), "This subject is already registered")
  }, ignoreInit = TRUE)

  # --- Overall form state --------------------------------------------------

  form_valid <- reactive({
    id_ok() && age_ok() && !duplicate() &&
      isTruthy(input$sex) &&
      !is.na(input$visit_date) && input$visit_date <= Sys.Date() &&
      isTRUE(input$consent)
  })

  observe({
    updateActionButton(session, "submit", disabled = !form_valid())
  })

  # --- Submit --------------------------------------------------------------

  observeEvent(input$submit, {
    req(form_valid())      # belt and braces: never trust the disabled state alone

    records(bind_rows(records(), tibble::tibble(
      subject_id = input$subject_id,
      age        = as.integer(input$age),
      sex        = input$sex,
      visit_date = input$visit_date
    )))

    updateTextInput(session, "subject_id", value = "")
    updateNumericInput(session, "age", value = NA)
    updateCheckboxInput(session, "consent", value = FALSE)

    showNotification("Subject registered", type = "message")
  })

  output$records <- renderTable(records())
}

Three points worth drawing out:

  • req(form_valid()) inside the submit handler. A disabled button is a UI convenience, not a security control — the client can enable it. Server-side revalidation is mandatory whenever the action has consequences.
  • The duplicate check depends on records(), so it re-evaluates after each submission rather than going stale.
  • Clearing the form after submit but leaving sex and date alone: when registering a batch of subjects at one visit, those fields are usually unchanged. Small decision, large difference in use.

Recap

  • Every output function pairs with exactly one render function; mismatches fail silently
  • numericInput() returns NA when empty, not NULL — guard with !is.na()
  • selectizeInput(server = TRUE) above ~1,000 choices
  • Action buttons start at 0, so ignoreInit = TRUE is usually right
  • req() to stop, validate(need()) to explain, shinyFeedback to point
  • Cascading update* observers must flow one way only
  • Always revalidate on the server before acting — a disabled button is not a control

Next: Modules — the lesson that determines whether your app scales.

Back to top