Shiny application structure

Lesson 1 — R Shiny

Lesson 1 of 11 Beginner to intermediate ~75 min

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 bslib for 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 filesui.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.

TipOr make it a package

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 app

The 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 wrapper
WarningHTML() 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 + Enter

Debugging 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 save

reactlog 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:

  1. The data file is read inside every render function — twice per change, for every user. It should be read once, globally.
  2. The filtering logic is duplicated. Two copies means two places to update, and they will drift.
  3. session is missing from the server signature, which rules out update* functions, session$userData, modules and cleanup handlers.
  4. 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)
The 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 in www/
  • bslib is the modern layout and theming system
  • options(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.

Back to top