Inputs and outputs
Lesson 3 — R Shiny
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"))selectizeInput 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 forPlot 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 equivalentobserveEvent(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$studyback to""must clear both levels below, otherwise a stale subject selection remains and the downstream filter returns rows from the wrong study.
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()returnsNAwhen empty, notNULL— guard with!is.na()selectizeInput(server = TRUE)above ~1,000 choices- Action buttons start at 0, so
ignoreInit = TRUEis usually right req()to stop,validate(need())to explain,shinyFeedbackto 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.