Organising Shiny Apps with Modules
Shiny apps start small. A few inputs, a couple of outputs, and everything lives comfortably in a single app.R file. Then requirements grow. The app adds tabs, more interactivity, and eventually you are scrolling through thousands of lines trying to find where that one reactive expression lives. Organising Shiny code into reusable components solves this, and Shiny modules are the mechanism that makes it possible.
Modules let you break your app into self-contained pieces. Each module has its own UI and server logic. You can reuse modules across apps, test them in isolation, and keep your code organized as projects scale.
What are Shiny modules?
A module is a pair of functions: one that defines the UI and one that defines the server logic. Unlike regular functions, modules use namespaces to avoid conflicts. When you call a module function, it returns UI elements with unique IDs that won’t clash with other parts of your app.
The simplest module wraps a single input and output:
library(shiny)
# Module UI function
simplePlotUI <- function(id) {
ns <- NS(id)
tagList(
selectInput(ns("var"), "Select variable",
choices = names(mtcars)),
plotOutput(ns("plot"))
)
}
# Module server function
simplePlotServer <- function(id) {
moduleServer(id, function(input, output, session) {
output$plot <- renderPlot({
hist(mtcars[[input$var]],
main = input$var,
col = "steelblue",
border = "white")
})
})
}
# App that uses the module
ui <- fluidPage(simplePlotUI("plot1"))
server <- function(input, output, session) {
simplePlotServer("plot1")
}
shinyApp(ui, server)
The NS(id) function creates a namespace. When you call simplePlotUI("plot1"), the select input gets ID plot1-var and the plot output gets ID plot1-plot. The moduleServer() function handles the server-side namespace automatically.
Building a multi-Tab app
Modules shine when you need repeated patterns. Consider an app with multiple analysis tabs, each showing the same type of visualization with different data. Instead of copying code, create one module and use it twice.
# Module for data exploration
explorerUI <- function(id, label) {
ns <- NS(id)
tagList(
h4(label),
fluidRow(
column(6, selectInput(ns("x"), "X variable", names(mtcars))),
column(6, selectInput(ns("y"), "Y variable", names(mtcars)))
),
plotOutput(ns("scatter")),
verbatimTextOutput(ns("summary"))
)
}
explorerServer <- function(id, data) {
moduleServer(id, function(input, output, session) {
output$scatter <- renderPlot({
req(input$x, input$y)
plot(mtcars[[input$x]], mtcars[[input$y]],
xlab = input$x, ylab = input$y,
pch = 19, col = "darkgray")
})
output$summary <- renderPrint({
req(input$x, input$y)
summary(mtcars[, c(input$x, input$y)])
})
})
}
# App with two instances of the same module
ui <- fluidPage(
tabsetPanel(
tabPanel("Engine analysis", explorerUI("eng", "Engine Variables")),
tabPanel("Size analysis", explorerUI("size", "Size Variables"))
)
)
server <- function(input, output, session) {
explorerServer("eng", mtcars)
explorerServer("size", mtcars)
}
shinyApp(ui, server)
Both tabs do the same thing—plot variables and show a summary—but they’re completely independent. Change the module code once, and both tabs update.
Passing data into modules
The example above passes mtcars directly. In real apps, you often need to pass reactive data or parameters. Modules handle this the same way as regular Shiny: use reactive expressions.
# Module that accepts reactive data
dataExplorerUI <- function(id) {
ns <- NS(id)
tagList(
selectInput(ns("var"), "Variable", choices = NULL),
verbatimTextOutput(ns("stats"))
)
}
dataExplorerServer <- function(id, data) {
moduleServer(id, function(input, output, session) {
# Update choices when data changes
observe({
req(data())
updateSelectInput(session, "var",
choices = names(data()))
})
output$stats <- renderPrint({
req(input$var, data())
summary(data()[[input$var]])
})
})
}
# Usage in app
ui <- fluidPage(dataExplorerUI("explorer"))
server <- function(input, output, session) {
# Reactive data source
dataset <- reactive({
# Could come from file, database, API
mtcars
})
dataExplorerServer("explorer", dataset)
}
The data parameter is a function that returns data. Inside the module, you call data() to get the current value. This makes the module respond to changes in the source data.
Module communication
Sometimes modules need to talk to each other. A common pattern: one module filters data, another displays it. You can connect modules using shared reactive values.
# Data filter module
filterModuleUI <- function(id) {
ns <- NS(id)
tagList(
sliderInput(ns("mpg"), "Min MPG", 10, 35, 20),
textOutput(ns("count"))
)
}
filterModuleServer <- function(id) {
moduleServer(id, function(input, output, session) {
filtered <- reactive({
mtcars[mtcars$mpg >= input$mpg, ]
})
output$count <- renderText({
nrow(filtered()) |> paste0(" cars")
})
# Return the reactive for other modules to use
filtered
})
}
# Display module
displayModuleUI <- function(id) {
ns <- NS(id)
plotOutput(ns("plot"))
}
displayModuleServer <- function(id, data) {
moduleServer(id, function(input, output, session) {
output$plot <- renderPlot({
req(data())
plot(data()$wt, data()$mpg,
xlab = "Weight", ylab = "MPG",
pch = 19, col = "steelblue")
})
})
}
# App wiring them together
ui <- fluidPage(
filterModuleUI("filter"),
displayModuleUI("display")
)
server <- function(input, output, session) {
filtered_data <- filterModuleServer("filter")
displayModuleServer("display", filtered_data)
}
The filter module returns a reactive expression. The display module accepts it as an argument. When the slider moves, the filter updates, and the plot automatically refreshes. This pattern — one module producing data, another consuming it, with the parent module wiring them together — is the standard way to compose Shiny modules. It keeps each module focused on a single responsibility and makes the data flow explicit rather than relying on hidden global state.
Organizing module files
As your app grows, keep modules in separate files. A common structure:
app/
├── app.R
├── modules/
│ ├── __init__.R
│ ├── dataInput.R
│ ├── plotViewer.R
│ └── tableDisplay.R
└── helpers.R
Each file contains one module pair. The __init__.R file loads all modules:
# modules/__init__.R
source("dataInput.R", local = TRUE)
source("plotViewer.R", local = TRUE)
source("tableDisplay.R", local = TRUE)
In app.R, source the directory:
source("modules/__init__.R", local = TRUE)
ui <- fluidPage(
dataInputUI("input1"),
plotViewerUI("plot1")
)
server <- function(input, output, session) {
data <- dataInputServer("input1")
plotViewerServer("plot1", data)
}
shinyApp(ui, server)
This structure scales well. You can have ten modules in a complex app and still navigate quickly to the file you need.
Module communication patterns
Modules communicate through return values and shared reactive values. A module that returns reactive expressions: moduleServer(id, function(input, output, session) { reactive({ input$value * 2 }) }) returns a reactive that the calling module can use. The parent module calls selected_data <- my_module_server("module1") and uses selected_data() to access the reactive value.
For bidirectional communication, pass reactive inputs to child modules: child_module_server("child", data = filtered_data) passes a reactive from the parent to the child. The child calls data() to access it. This creates a clean dependency graph where data flows down and events/selections flow up.
Testing modules
shiny::testServer(module_server, args = list(...), {session$setInputs(x = 5); expect_equal(output$result, 10)}) tests module server logic in isolation. Pass any reactive arguments as values in args. This is faster than full app testing because it does not render the UI or run a browser.
For integration tests with shinytest2, create a minimal test app that uses the module and test that UI interactions produce the expected outputs. Test critical user flows through the module rather than testing every reactive internally.
Organization for large apps
In large Shiny apps, organize modules into a modules/ directory with one file per module or one file per feature area. Use consistent naming: mod_feature_ui.R and mod_feature_server.R, or mod_feature.R containing both. Document each module’s inputs (reactive arguments), outputs (return values), and side effects (database writes, file output).
The golem framework provides a complete project structure for large Shiny apps with modules, unit testing, and deployment configuration built in. For simpler apps, the module pattern from usethis::use_module("feature") provides enough structure.
Communication between modules
Modules communicate through reactive values passed as arguments. A parent module creates a reactiveVal or reactive() and passes it to a child module’s server function. The child reads or writes through that reactive, keeping data flow explicit. Avoid using global reactiveValues for inter-module communication, it creates hidden coupling that makes the app harder to test.
For sibling modules that need to share data, lift the shared state up to the parent module and pass it down to each sibling. This mirrors the “props down, events up” pattern from React and keeps data flow unidirectional.
Module testing
Test modules in isolation using testServer() from shinytest2. Pass mock inputs and reactives to the module server function and assert on outputs without rendering the full UI. This is much faster than end-to-end browser tests and catches logic errors in reactive graphs early.
testServer(myModuleServer, args = list(data = reactive(mock_df)), {
session$setInputs(threshold = 5)
expect_equal(output$count, 3)
})
When to modularize
Introduce a module when: the same UI pattern appears in more than one place, a feature grows large enough that it is hard to navigate in one file, or you want to write isolated tests for a subset of the app’s logic. Modularizing too early adds boilerplate; the right time is when the pain of not having a module is greater than the cost of creating one.
When to use modules
Not every app needs modules. A simple single-file app with five inputs and two outputs is easier to read as-is. Modules add overhead: extra function definitions, namespace management, and another layer of indirection.
Consider modules when your app meets any of these conditions:
- Multiple similar sections that could share code
- A team working on different features simultaneously
- Tests you want to run on individual components
- Reusable components you plan to use across apps
Start simple. If your app.R passes 500 lines, think about extracting a module. If you’re copying and pasting UI or server code, definitely extract a module.
Modules transform Shiny from a quick prototyping tool into something capable of production applications. The initial setup takes a little longer, but the maintainability gains are substantial. Your future self, scrolling through well-organized code, will thank you.
See also
- Building R Packages — Structure your R code for reuse
- Debugging R Code — Troubleshooting Shiny apps
- Interactive Maps with leaflet — Another Shiny-compatible interactive component