Modules

Lesson 4 — R Shiny

Lesson 4 of 11 Intermediate ~100 min

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:

  1. ns <- NS(id) at the top of the UI function
  2. Wrap every ID in the UI with ns()
  3. 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.

WarningThe most common module bug

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())
}
TipDesign the contract first

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.

Shared state object

For genuinely app-wide state — the logged-in user, the selected study — a shared reactiveValues passed to every module is acceptable:

server <- function(input, output, session) {
  app_state <- reactiveValues(
    user       = NULL,
    study_id   = NULL,
    selected   = NULL
  )

  mod_login_server("login",   state = app_state)
  mod_study_server("study",   state = app_state)
  mod_review_server("review", state = app_state)
}

The cost is that any module can write any field, so the flow of data is no longer visible from the parent. Use it sparingly and document which module owns which field.

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_id

Not 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 same id creates 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:

  1. 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.
  2. Extract one seam. Move its UI into mod_x_ui(), its server logic into mod_x_server(), add ns() to the IDs.
  3. Define the interface. What does it need (arguments) and what does it produce (return value)? Write that down before moving code.
  4. Test it in isolation with testServer() — see Testing Shiny applications.
  5. 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.

Note that the two instances are wired together by the parent — the subjects table does not know the events table exists.

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.
The cost is four files instead of one. That trade is clearly worth it at three tabs and overwhelmingly worth it at ten.

Recap

  • NS(id) in the UI, ns() around every ID, moduleServer() in the server
  • session$ns inside renderUI(), not NS(id)
  • Pass reactives in (data, not data()), 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.

Back to top