Shiny application structure
Lesson 1 — R Shiny
Learning objectives
- Build and run a minimal Shiny application
- Explain what the UI and server functions each do, and when each runs
- Choose a file layout that scales past one screen of code
- Use
bslibfor layout and theming - Know what runs once, once per session, and once per reaction
The smallest app
library(shiny)
ui <- fluidPage(
titlePanel("Hello Shiny"),
sliderInput("n", "Number of observations", min = 10, max = 500, value = 100),
plotOutput("hist")
)
server <- function(input, output, session) {
output$hist <- renderPlot({
hist(rnorm(input$n), col = "#1e4270", border = "white",
main = paste(input$n, "random normal values"))
})
}
shinyApp(ui, server)Three pieces:
ui— an R object describing HTML. It is evaluated once when the app starts and sent to the browser.server— a function called once per user session, wiring inputs to outputs.shinyApp()— starts the application.
The connection between them is by ID. sliderInput("n", ...) creates input$n; plotOutput("hist") is filled by output$hist. Those strings must match exactly, and a typo produces a blank space with no error — the single most common beginner frustration.
What runs when
This is worth internalising early:
library(shiny)
library(dplyr)
# 1. GLOBAL — once, when the process starts.
# Shared by all users. Put expensive, read-only setup here.
adsl <- readRDS("data/adsl.rds")
lookup <- readr::read_csv("data/lookup.csv")
ui <- fluidPage(...) # 2. Evaluated once at startup
server <- function(input, output, session) {
# 3. SESSION — once per user connection.
# Each user gets their own copy of everything here.
user_selections <- reactiveValues(filters = NULL)
output$plot <- renderPlot({
# 4. REACTIVE — every time a dependency changes.
ggplot(filter(adsl, ARM == input$arm), aes(AGE)) + geom_histogram()
})
}
shinyApp(ui, server)| Scope | Runs | Use for |
|---|---|---|
| Global | Once per process | Loading reference data, library() calls, source files |
| UI | Once per process | The static page structure |
| Server body | Once per session | Per-user state, defaults |
| Reactive | On dependency change | Anything that responds to input |
A common performance mistake is reading a 200 MB dataset inside server, which reloads it for every user. Read it globally.
File layout
One file — app.R — for anything under ~150 lines:
library(shiny)
ui <- fluidPage(...)
server <- function(input, output, session) {...}
shinyApp(ui, server)Two files — ui.R and server.R — the older convention. You will see it a lot; there is no reason to choose it for new work.
A project — for anything real:
myapp/
├── app.R # library() calls, source(), shinyApp()
├── R/ # auto-sourced by Shiny (1.5+)
│ ├── mod_filters.R
│ ├── mod_table.R
│ └── utils_data.R
├── data/
│ └── adsl.rds
├── www/ # static assets served at /
│ ├── custom.css
│ └── logo.png
├── tests/
│ └── testthat/
└── renv.lock
Everything in R/ is sourced automatically at startup — no source() calls needed. This is the layout to default to.
golem and rhino structure a Shiny app as an R package, which gives you tests, documentation, dependency declaration and R CMD check for free. For an app that will be validated or maintained by a team, that is the right choice. See Production application design.
Layout with bslib
bslib is the modern layout and theming system. Prefer it over raw fluidPage() for new apps.
library(shiny)
library(bslib)
ui <- page_sidebar(
title = "Study ABC-101 Data Review",
theme = bs_theme(
version = 5,
bootswatch = "flatly",
primary = "#16355e",
base_font = font_google("Source Sans 3")
),
sidebar = sidebar(
title = "Filters",
selectInput("arm", "Treatment arm",
choices = c("All", "Placebo", "Drug A", "Drug B")),
sliderInput("age", "Age range", min = 18, max = 90, value = c(18, 90)),
checkboxInput("saffl", "Safety population only", value = TRUE),
hr(),
downloadButton("dl", "Download filtered data")
),
layout_columns(
col_widths = c(4, 4, 4),
value_box(title = "Subjects", value = textOutput("n_subj"),
showcase = bsicons::bs_icon("people")),
value_box(title = "Mean age", value = textOutput("mean_age"),
showcase = bsicons::bs_icon("calendar")),
value_box(title = "Female", value = textOutput("pct_f"),
showcase = bsicons::bs_icon("gender-female"))
),
navset_card_tab(
nav_panel("Table", DT::DTOutput("table")),
nav_panel("Plot", plotOutput("plot")),
nav_panel("Summary", verbatimTextOutput("summary"))
)
)Key bslib pieces:
| Function | Purpose |
|---|---|
page_sidebar() |
Page with a collapsible sidebar |
page_navbar() |
Multi-page app with a top navbar |
page_fillable() |
Fills the viewport height — good for dashboards |
card(), card_header(), card_body() |
The basic content container |
layout_columns() |
Responsive grid |
layout_sidebar() |
Sidebar within a card |
value_box() |
Headline number |
navset_card_tab() |
Tabs inside a card |
accordion() |
Collapsible sections |
Theme live, while the app is running:
bs_theme_preview() # interactive theme designer
run_with_themer(shinyApp(ui, server)) # theme the running appThe older layouts
You will meet these in existing code:
# Grid: 12 columns per fluidRow
fluidPage(
fluidRow(
column(4, selectInput("x", "X", names(mtcars))),
column(8, plotOutput("plot"))
)
)
# Sidebar
fluidPage(
sidebarLayout(
sidebarPanel(width = 3, sliderInput("n", "N", 1, 100, 50)),
mainPanel(width = 9, plotOutput("plot"))
)
)
# Tabs
tabsetPanel(
tabPanel("Data", tableOutput("t")),
tabPanel("Plot", plotOutput("p"))
)
# shinydashboard — very common, now largely superseded by bslib
dashboardPage(
dashboardHeader(title = "Study Review"),
dashboardSidebar(sidebarMenu(menuItem("Data", tabName = "data"))),
dashboardBody(tabItems(tabItem("data", ...)))
)These all still work. bslib gives better mobile behaviour, easier theming and active development.
HTML from R
Shiny’s UI functions generate HTML:
h1("Heading"); h2("Sub"); p("Paragraph")
div(class = "alert alert-info", "Note")
span(style = "color: #05606b;", "Coloured text")
tags$ul(tags$li("one"), tags$li("two"))
a("Link", href = "https://example.com", target = "_blank")
br(); hr()
HTML("<strong>Raw HTML</strong>") # careful — see below
tagList(h3("Title"), p("Body")) # several elements, no wrapperHTML() and user input
HTML() inserts unescaped markup. Passing user-supplied text through it is a cross-site scripting vulnerability:
# DANGEROUS
output$greeting <- renderUI(HTML(paste0("Hello, ", input$name)))
# Safe — Shiny escapes automatically
output$greeting <- renderUI(tags$p("Hello, ", input$name))Only use HTML() on strings you constructed yourself.
Add CSS and JavaScript from www/:
ui <- fluidPage(
tags$head(
tags$link(rel = "stylesheet", type = "text/css", href = "custom.css"),
tags$script(src = "custom.js")
),
...
)Running and debugging
runApp() # from the app directory
runApp("path/to/app")
runApp(port = 3838)
runApp(launch.browser = TRUE)
runApp(host = "0.0.0.0", port = 3838) # accessible on the network
# In RStudio: the "Run App" button, or Ctrl/Cmd + Shift + EnterDebugging tools:
options(shiny.reactlog = TRUE) # then press Ctrl/Cmd + F3 in the browser
options(shiny.error = browser) # drop into the debugger on error
options(shiny.trace = TRUE) # log every websocket message
options(shiny.autoreload = TRUE) # reload on file savereactlog is the tool that makes reactivity comprehensible — it draws the dependency graph and lets you step through invalidations. Use it the first time something reactive behaves unexpectedly.
A complete small app
library(shiny)
library(bslib)
library(dplyr)
library(ggplot2)
# Global: loaded once, shared by all sessions
adsl <- readRDS("data/adsl.rds")
ui <- page_sidebar(
title = "ADSL Explorer",
theme = bs_theme(version = 5, primary = "#16355e"),
sidebar = sidebar(
selectInput("arm", "Treatment arm",
choices = c("All", sort(unique(adsl$TRT01P))),
selected = "All"),
sliderInput("age", "Age range",
min = min(adsl$AGE, na.rm = TRUE),
max = max(adsl$AGE, na.rm = TRUE),
value = range(adsl$AGE, na.rm = TRUE)),
checkboxInput("saffl", "Safety population only", TRUE)
),
layout_columns(
col_widths = c(6, 6),
value_box("Subjects", textOutput("n_subj")),
value_box("Mean age", textOutput("mean_age"))
),
card(
card_header("Age distribution"),
plotOutput("plot", height = "320px")
)
)
server <- function(input, output, session) {
filtered <- reactive({
d <- adsl
if (input$arm != "All") d <- filter(d, TRT01P == input$arm)
if (isTRUE(input$saffl)) d <- filter(d, SAFFL == "Y")
filter(d, AGE >= input$age[1], AGE <= input$age[2])
})
output$n_subj <- renderText(nrow(filtered()))
output$mean_age <- renderText(sprintf("%.1f", mean(filtered()$AGE, na.rm = TRUE)))
output$plot <- renderPlot({
req(nrow(filtered()) > 0)
ggplot(filtered(), aes(AGE, fill = TRT01P)) +
geom_histogram(binwidth = 5, colour = "white") +
labs(x = "Age (years)", y = "Subjects", fill = "Arm") +
theme_minimal(base_size = 13)
})
}
shinyApp(ui, server)Note filtered() — the filtering logic is written once as a reactive() and used by three outputs. That is the central idea of the next lesson.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Mismatched input/output IDs | Blank space, no error | Check the strings match |
| Duplicate IDs | Only one updates | IDs must be unique across the app |
Loading data inside server |
Slow, memory per user | Load globally |
library() calls only in ui.R |
Server errors | Put them in app.R or global.R |
Assets not in www/ |
404 for CSS and images | www/ is served at / |
Forgetting session in the signature |
Some features unavailable | Always function(input, output, session) |
print() for debugging |
Output goes to the R console, not the browser | Fine, but reactlog is better |
Exercise 1.1 — Build a two-input app
Build an app with a selectInput for a mtcars column and a sliderInput for the number of bins, producing a histogram of the selected column with the chosen number of bins. Show the mean and SD as text underneath.
Show solution
library(shiny)
library(bslib)
library(ggplot2)
numeric_cols <- names(mtcars)[sapply(mtcars, is.numeric)]
ui <- page_sidebar(
title = "mtcars explorer",
sidebar = sidebar(
selectInput("var", "Variable", choices = numeric_cols, selected = "mpg"),
sliderInput("bins", "Number of bins", min = 5, max = 50, value = 20)
),
card(
card_header(textOutput("title", inline = TRUE)),
plotOutput("hist"),
card_footer(textOutput("stats"))
)
)
server <- function(input, output, session) {
values <- reactive({
req(input$var)
mtcars[[input$var]]
})
output$title <- renderText(paste("Distribution of", input$var))
output$hist <- renderPlot({
ggplot(data.frame(x = values()), aes(x)) +
geom_histogram(bins = input$bins, fill = "#16355e", colour = "white") +
labs(x = input$var, y = "Count") +
theme_minimal(base_size = 13)
})
output$stats <- renderText({
sprintf("n = %d mean = %.2f SD = %.2f",
length(values()), mean(values()), sd(values()))
})
}
shinyApp(ui, server)The values() reactive is doing the important work: both the plot and the text depend on it, so the column extraction happens once per change rather than twice. With mtcars that is irrelevant; with a database query it is the difference between one round trip and two.
req(input$var) guards against the brief moment during startup when inputs are NULL.
Exercise 1.2 — Restructure a flat app
This app works but is badly structured. Identify four problems and restructure it.
library(shiny)
ui <- fluidPage(
selectInput("arm", "Arm", c("A", "B")),
plotOutput("p"),
tableOutput("t")
)
server <- function(input, output) {
output$p <- renderPlot({
d <- readRDS("data/adsl.rds")
d <- d[d$ARM == input$arm, ]
hist(d$AGE)
})
output$t <- renderTable({
d <- readRDS("data/adsl.rds")
d <- d[d$ARM == input$arm, ]
head(d)
})
}
shinyApp(ui, server)Show solution
Problems:
- The data file is read inside every render function — twice per change, for every user. It should be read once, globally.
- The filtering logic is duplicated. Two copies means two places to update, and they will drift.
sessionis missing from the server signature, which rules outupdate*functions,session$userData, modules and cleanup handlers.- The arm choices are hard-coded rather than derived from the data, so the app silently misses a third arm.
A fifth, smaller issue: there is no guard against an empty filter result.
library(shiny)
library(bslib)
library(dplyr)
library(ggplot2)
# Once per process
adsl <- readRDS("data/adsl.rds")
ui <- page_sidebar(
title = "ADSL by arm",
sidebar = sidebar(
selectInput("arm", "Treatment arm", choices = sort(unique(adsl$ARM)))
),
card(card_header("Age distribution"), plotOutput("p")),
card(card_header("First rows"), tableOutput("t"))
)
server <- function(input, output, session) {
# Written once, used twice
filtered <- reactive({
req(input$arm)
filter(adsl, ARM == input$arm)
})
output$p <- renderPlot({
validate(need(nrow(filtered()) > 0, "No subjects in this arm."))
ggplot(filtered(), aes(AGE)) +
geom_histogram(binwidth = 5, fill = "#16355e", colour = "white") +
theme_minimal(base_size = 13)
})
output$t <- renderTable(head(filtered()))
}
shinyApp(ui, server)filtered() reactive also gives a performance benefit that is easy to miss: Shiny caches its value, so changing something unrelated to the filter does not re-run it.
Recap
- UI and server are connected by matching ID strings — typos fail silently
- Global code runs once per process; the server function runs once per session
- Load data globally, never inside a render function
- Files in
R/are sourced automatically; static assets go inwww/ bslibis the modern layout and theming systemoptions(shiny.reactlog = TRUE)and Ctrl/Cmd+F3 to see the reactive graph- Extract shared logic into a
reactive()— never duplicate it across outputs
Next: Reactive programming — the part that is genuinely new.