Modules
Lesson 4 — R Shiny
Learning objectives
- Explain the namespace problem that modules solve
- Write a module UI and server pair correctly
- Pass reactive values into a module and return them out
- Communicate between sibling modules without coupling them
- Nest modules and know when to stop
- Refactor an existing monolithic app into modules
The problem
IDs in Shiny are global. Two copies of the same UI block collide:
ui <- fluidPage(
# Panel 1
selectInput("arm", "Arm", arms),
plotOutput("plot"),
# Panel 2 — same IDs, broken
selectInput("arm", "Arm", arms),
plotOutput("plot")
)The workaround people reach for first is manual prefixing:
selectInput("panel1_arm", ...); plotOutput("panel1_plot")
selectInput("panel2_arm", ...); plotOutput("panel2_plot")That works until you have eight panels and a server function full of input$panel3_arm. Modules do the prefixing for you and, more importantly, give each block its own scope.
A module
Two functions, by convention named xxx_ui() and xxx_server().
# R/mod_filter.R
#' Filter panel UI
#' @param id Module namespace ID.
mod_filter_ui <- function(id) {
ns <- NS(id) # 1. create the namespace function
tagList(
selectInput(ns("arm"), "Treatment arm", # 2. wrap every ID
choices = c("All", "Placebo", "Drug A", "Drug B")),
sliderInput(ns("age"), "Age range", 18, 90, c(18, 90)),
checkboxInput(ns("saffl"), "Safety population only", TRUE)
)
}
#' Filter panel server
#' @param id Module namespace ID.
#' @param data A reactive returning the dataset to filter.
#' @return A reactive returning the filtered dataset.
mod_filter_server <- function(id, data) {
moduleServer(id, function(input, output, session) {
filtered <- reactive({
d <- data()
if (input$arm != "All") d <- dplyr::filter(d, TRT01P == input$arm)
if (isTRUE(input$saffl)) d <- dplyr::filter(d, SAFFL == "Y")
dplyr::filter(d, AGE >= input$age[1], AGE <= input$age[2])
})
filtered # 3. return a reactive
})
}Used in the app:
ui <- page_sidebar(
sidebar = sidebar(mod_filter_ui("filters")),
card(DT::DTOutput("table"))
)
server <- function(input, output, session) {
adsl <- reactive(readRDS("data/adsl.rds"))
filtered <- mod_filter_server("filters", data = adsl)
output$table <- DT::renderDT(filtered())
}Three rules, and they are the whole of module mechanics:
ns <- NS(id)at the top of the UI function- Wrap every ID in the UI with
ns() - Wrap the server body in
moduleServer(id, function(input, output, session) {...})
Inside the module server, input$arm refers to the namespaced input automatically — you do not write ns() there.
Forgetting ns() on one input. The UI renders, the app runs, and that one control does nothing — no error, no warning. When a module control is inert, check ns() first, every time.
renderUI() inside a module is the sneakiest case, because you need session$ns():
output$dynamic <- renderUI({
ns <- session$ns # NOT NS(id) — the id is not in scope here
selectInput(ns("choice"), "Choose", choices = letters)
})Passing data in
Pass reactives, not values, so the module tracks changes:
# WRONG — data is evaluated once, the module never updates
mod_table_server("t1", data = filtered())
# RIGHT — the module calls data() itself and takes a dependency
mod_table_server("t1", data = filtered)Inside, call it: data().
You can pass static configuration too:
mod_table_server <- function(id, data, columns, page_size = 25, title = NULL) {
moduleServer(id, function(input, output, session) {
output$tbl <- DT::renderDT({
DT::datatable(dplyr::select(data(), dplyr::all_of(columns)),
options = list(pageLength = page_size))
})
})
}
mod_table_server("t1", data = filtered,
columns = c("USUBJID", "AGE", "SEX"), page_size = 10)Returning values out
A module can return a single reactive, or a list of them:
mod_selection_server <- function(id, data) {
moduleServer(id, function(input, output, session) {
output$table <- DT::renderDT(DT::datatable(data(), selection = "single"))
selected_row <- reactive({
req(input$table_rows_selected)
data()[input$table_rows_selected, ]
})
# Return several things
list(
selected = selected_row,
count = reactive(nrow(data())),
reset = reactive(input$reset)
)
})
}server <- function(input, output, session) {
sel <- mod_selection_server("sel", data = filtered)
output$detail <- renderPrint(sel$selected())
output$n <- renderText(sel$count())
}Before writing a module, write down its signature:
mod_lab_plot_server(id, data, param, highlight_subject = reactive(NULL))
-> list(clicked_point = reactive(), n_points = reactive())
A module with eight arguments and a list of six returns is doing too much. Split it. The best modules have one clear responsibility and a signature that fits on a line.
Communication between modules
Parent as mediator (preferred)
The parent wires modules together; the modules do not know about each other.
server <- function(input, output, session) {
raw <- mod_upload_server("upload") # returns reactive(data)
filt <- mod_filter_server("filters", data = raw)
sel <- mod_table_server("table", data = filt) # returns list(selected=...)
mod_detail_server("detail", subject = sel$selected)
mod_plot_server("plot", data = filt, highlight = sel$selected)
}This is the pattern to default to. Every dependency is visible in one place, and any module can be tested in isolation because it only takes reactives.
session$userData
An environment shared across all modules in a session:
# In the parent
session$userData$user_id <- get_user(session)
# In any module
current_user <- session$userData$user_idNot reactive. Fine for constants set once at session start; wrong for anything that changes.
Nesting modules
Modules compose. IDs nest automatically:
mod_page_ui <- function(id) {
ns <- NS(id)
tagList(
mod_filter_ui(ns("filters")), # note: ns() around the child's id
mod_table_ui(ns("table"))
)
}
mod_page_server <- function(id, data) {
moduleServer(id, function(input, output, session) {
filt <- mod_filter_server("filters", data = data) # no ns() here
mod_table_server("table", data = filt)
})
}The resulting input ID is page-filters-arm. ns() in the UI, bare ID in the server — because moduleServer() already establishes the namespace.
Two or three levels is comfortable. Beyond that, passing a reactive from a deeply nested module up to the top becomes tedious, which is usually a signal that the structure is wrong.
Dynamic modules
Create module instances at runtime:
server <- function(input, output, session) {
panels <- reactiveVal(character())
observeEvent(input$add_panel, {
id <- paste0("panel_", length(panels()) + 1)
panels(c(panels(), id))
insertUI(
selector = "#panel_container",
where = "beforeEnd",
ui = mod_analysis_ui(session$ns(id))
)
mod_analysis_server(id, data = filtered)
})
}Two things to watch:
session$ns(id)in the UI so the inserted markup is namespaced correctly.- Calling
mod_analysis_server(id, ...)twice with the sameidcreates a duplicate set of observers. Track which IDs are live and never reuse one.
Refactoring a monolith
A practical sequence for an existing 1,500-line server.R:
- Find the seams. Look for groups of inputs and outputs that are used together and not elsewhere — usually a tab, a sidebar section, or a card.
- Extract one seam. Move its UI into
mod_x_ui(), its server logic intomod_x_server(), addns()to the IDs. - Define the interface. What does it need (arguments) and what does it produce (return value)? Write that down before moving code.
- Test it in isolation with
testServer()— see Testing Shiny applications. - Repeat. Do not attempt the whole app in one change.
The seam that is usually easiest to extract first is a table with its own filters, because its interface is obviously data in, selection out.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Missing ns() on one input |
That control does nothing | Check every ID |
NS(id) inside renderUI |
Namespace wrong | session$ns |
Passing data() instead of data |
Module never updates | Pass the reactive itself |
| Returning a value not a reactive | Consumer gets a stale snapshot | Return reactive(...) |
| Modules reaching into each other | Untestable, tightly coupled | Mediate through the parent |
| One giant module | Same problem, new wrapper | One responsibility per module |
| Reusing a dynamic module ID | Duplicate observers, doubled events | Track live IDs |
callModule() in new code |
Superseded since Shiny 1.5 | moduleServer() |
The old API
You will see this in existing apps:
# Old (Shiny < 1.5)
callModule(mod_filter, "filters", data = adsl)
mod_filter <- function(input, output, session, data) {
ns <- session$ns
...
}Equivalent to moduleServer() but with the arguments in an awkward order. There is no need to migrate working code, but write new modules with moduleServer().
Exercise 4.1 — Write a reusable table module
Write mod_datatable_ui() / mod_datatable_server() that displays a data frame with a title, a row count, a CSV download button, and returns the currently selected row as a reactive. Then use two instances in one app.
Show solution
# R/mod_datatable.R
mod_datatable_ui <- function(id, title = NULL) {
ns <- NS(id)
bslib::card(
bslib::card_header(
class = "d-flex justify-content-between align-items-center",
tags$span(title),
tags$span(class = "text-muted small", textOutput(ns("count"), inline = TRUE))
),
DT::DTOutput(ns("table")),
bslib::card_footer(downloadButton(ns("download"), "Download CSV",
class = "btn-sm"))
)
}
mod_datatable_server <- function(id, data, page_size = 10,
filename_prefix = "data") {
stopifnot(is.reactive(data))
moduleServer(id, function(input, output, session) {
output$count <- renderText({
sprintf("%s rows", format(nrow(data()), big.mark = ","))
})
output$table <- DT::renderDT({
DT::datatable(
data(),
rownames = FALSE,
selection = "single",
filter = "top",
options = list(pageLength = page_size, scrollX = TRUE)
)
})
output$download <- downloadHandler(
filename = function() {
sprintf("%s_%s.csv", filename_prefix, format(Sys.Date(), "%Y%m%d"))
},
content = function(file) {
readr::write_csv(data(), file)
}
)
# Return the selected row, or NULL when nothing is selected
reactive({
idx <- input$table_rows_selected
if (is.null(idx)) NULL else data()[idx, , drop = FALSE]
})
})
}Two instances:
ui <- bslib::page_fillable(
bslib::layout_columns(
col_widths = c(6, 6),
mod_datatable_ui("subjects", "Subjects"),
mod_datatable_ui("events", "Adverse events")
),
bslib::card(bslib::card_header("Selected"), verbatimTextOutput("detail"))
)
server <- function(input, output, session) {
adsl <- reactive(readRDS("data/adsl.rds"))
adae <- reactive(readRDS("data/adae.rds"))
sel_subj <- mod_datatable_server("subjects", data = adsl,
filename_prefix = "adsl")
# The events table shows only the selected subject's events
subject_events <- reactive({
if (is.null(sel_subj())) adae()
else dplyr::filter(adae(), USUBJID == sel_subj()$USUBJID)
})
sel_ae <- mod_datatable_server("events", data = subject_events,
filename_prefix = "adae")
output$detail <- renderPrint({
validate(need(!is.null(sel_ae()), "Select an adverse event."))
as.data.frame(sel_ae())
})
}The stopifnot(is.reactive(data)) at the top of the module server is worth having: passing data() instead of data is the most common way to misuse a module, and this turns a silent non-updating table into an immediate, clear error.
Exercise 4.2 — Refactor a monolith
This app has three tabs sharing a filter. Refactor it into modules.
ui <- fluidPage(
selectInput("arm", "Arm", c("All", "Placebo", "Drug A")),
sliderInput("age", "Age", 18, 90, c(18, 90)),
tabsetPanel(
tabPanel("Table", DT::DTOutput("table")),
tabPanel("Plot", plotOutput("plot")),
tabPanel("Summary", verbatimTextOutput("summary"))
)
)
server <- function(input, output, session) {
filtered <- reactive({
d <- adsl
if (input$arm != "All") d <- filter(d, TRT01P == input$arm)
filter(d, AGE >= input$age[1], AGE <= input$age[2])
})
output$table <- DT::renderDT(filtered())
output$plot <- renderPlot(ggplot(filtered(), aes(AGE)) + geom_histogram())
output$summary <- renderPrint(summary(filtered()))
}Show solution
Four modules: one for the filter, one per tab.
# R/mod_filter.R
mod_filter_ui <- function(id) {
ns <- NS(id)
tagList(
selectInput(ns("arm"), "Treatment arm", c("All", "Placebo", "Drug A")),
sliderInput(ns("age"), "Age range", 18, 90, c(18, 90)),
actionButton(ns("reset"), "Reset", class = "btn-sm btn-outline-secondary")
)
}
mod_filter_server <- function(id, data) {
stopifnot(is.reactive(data))
moduleServer(id, function(input, output, session) {
observeEvent(input$reset, {
updateSelectInput(session, "arm", selected = "All")
updateSliderInput(session, "age", value = c(18, 90))
})
reactive({
d <- data()
if (input$arm != "All") d <- dplyr::filter(d, TRT01P == input$arm)
dplyr::filter(d, AGE >= input$age[1], AGE <= input$age[2])
})
})
}# R/mod_table.R
mod_table_ui <- function(id) DT::DTOutput(NS(id, "table"))
mod_table_server <- function(id, data) {
moduleServer(id, function(input, output, session) {
output$table <- DT::renderDT(DT::datatable(data(), rownames = FALSE))
})
}# R/mod_plot.R
mod_plot_ui <- function(id) {
ns <- NS(id)
tagList(
numericInput(ns("binwidth"), "Bin width", 5, min = 1, max = 20, width = "160px"),
plotOutput(ns("plot"))
)
}
mod_plot_server <- function(id, data) {
moduleServer(id, function(input, output, session) {
output$plot <- renderPlot({
validate(need(nrow(data()) > 0, "No subjects match the current filters."))
ggplot2::ggplot(data(), ggplot2::aes(AGE)) +
ggplot2::geom_histogram(binwidth = input$binwidth,
fill = "#16355e", colour = "white") +
ggplot2::theme_minimal(base_size = 13)
}, res = 96)
})
}# R/mod_summary.R
mod_summary_ui <- function(id) verbatimTextOutput(NS(id, "summary"))
mod_summary_server <- function(id, data) {
moduleServer(id, function(input, output, session) {
output$summary <- renderPrint(summary(as.data.frame(data())))
})
}# app.R
ui <- bslib::page_sidebar(
title = "ADSL Explorer",
sidebar = bslib::sidebar(mod_filter_ui("filters")),
bslib::navset_card_tab(
bslib::nav_panel("Table", mod_table_ui("table")),
bslib::nav_panel("Plot", mod_plot_ui("plot")),
bslib::nav_panel("Summary", mod_summary_ui("summary"))
)
)
server <- function(input, output, session) {
adsl_r <- reactive(readRDS("data/adsl.rds"))
filtered <- mod_filter_server("filters", data = adsl_r)
mod_table_server("table", data = filtered)
mod_plot_server("plot", data = filtered)
mod_summary_server("summary", data = filtered)
}
shinyApp(ui, server)What the refactor bought:
- The plot module gained a bin-width control without touching anything else. In the monolith that meant adding an input to a shared sidebar and hoping it did not affect the other tabs.
- The filter module gained a Reset button, self-contained.
- Each module can be tested with
testServer()by passing a fixed tibble. - The parent server is six lines and shows the entire data flow.
Recap
NS(id)in the UI,ns()around every ID,moduleServer()in the serversession$nsinsiderenderUI(), notNS(id)- Pass reactives in (
data, notdata()), return reactives out - Let the parent wire modules together; modules should not know about siblings
- Design the module’s interface before writing its body
- One responsibility per module — a module with eight arguments should be split
- Refactor a monolith one seam at a time, testing as you go
Next: Dynamic user interfaces.