Shiny Module Architecture
shiny-modules.RmdWhat is a Shiny module?
A Shiny module is a self-contained unit of UI and server logic identified by a namespace ID. Modules solve two problems that appear as apps grow:
-
ID collisions: two inputs named
"genre"in different parts of the app would clash. Modules prefix every ID with their namespace so"filters-genre"and"plot-genre"never conflict. -
Coupling: without modules, server logic for
unrelated features shares the same
input,output, andsessionobjects. Modules give each feature its own scope.
Every module is a pair of functions: a UI function
that renders HTML, and a server function that contains
reactive logic. movexplR6 has two modules:
mod_filters and mod_plot.
Namespace isolation
The shiny::NS(id) call inside each UI function returns a
namespacing function. Every input and output ID is wrapped with it:
mod_filters_ui <- function(id) {
ns <- shiny::NS(id) # ns("reviews") becomes "filters-reviews"
shiny::sliderInput(ns("reviews"), ...)
}When the server function calls
shiny::moduleServer(id, ...), Shiny automatically applies
the same namespace to input, output, and
session, so input$reviews inside the module
body refers to "filters-reviews" in the global session
(without the module author or the caller having to think about it).
Data flow
The server function in movies_server.R acts as the
conductor. It owns the MovieData object, calls
$filter() to derive the movies reactive, and
passes both movies and filters down to
mod_plot_server().
mod_filters
UI
mod_filters_ui(id) renders two bslib::card
elements. one for filter controls, and one for axis variable
selectors.
mod_filters_ui <- function(id) {
ns <- shiny::NS(id)
shiny::tagList(
bslib::card(
bslib::card_header("Filters"),
shiny::sliderInput(ns("reviews"), "Min reviews", 10, 300, 80, step = 10),
shiny::sliderInput(ns("year"), "Year released", 1940, 2014,
value = c(1970, 2014), sep = ""),
shiny::selectInput(ns("genre"), "Genre", choices = c("All", ...)),
shiny::textInput(ns("director"), "Director name contains"),
shiny::textInput(ns("cast"), "Cast name contains")
),
bslib::card(
bslib::card_header("Axes"),
shiny::selectInput(ns("xvar"), "X-axis variable", choices = axis_vars),
shiny::selectInput(ns("yvar"), "Y-axis variable", choices = axis_vars)
)
)
}Server
mod_filters_server(id) returns a
reactive list. This is the module’s output (the value it hands back to
whoever called it).
mod_filters_server <- function(id) {
shiny::moduleServer(id, function(input, output, session) {
shiny::reactive({
list(
reviews = input$reviews,
oscars = input$oscars,
year = input$year,
boxoffice = input$boxoffice,
genre = input$genre,
director = input$director,
cast = input$cast,
xvar = input$xvar,
yvar = input$yvar
)
})
})
}Returning a single reactive list rather than nine
individual reactives keeps the interface between modules simple. The
caller receives one object, calls it as filters(), and gets
all current values at once.
Testing mod_filters_server
shiny::testServer() lets you set inputs and inspect the
returned reactive without running a browser:
shiny::testServer(mod_filters_server, {
session$setInputs(
reviews = 120, oscars = 1,
year = c(2000, 2010), boxoffice = c(0, 500),
genre = "Action", director = "Nolan", cast = "",
xvar = "Meter", yvar = "Reviews"
)
result <- session$returned()
result$reviews # 120
result$genre # "Action"
})session$returned() retrieves whatever the module server
returned - here the reactive list.
mod_plot
UI
mod_plot_ui(id) renders a single
bslib::card containing a plotly output and a
text output for the movie count.
mod_plot_ui <- function(id) {
ns <- shiny::NS(id)
bslib::card(
bslib::card_header("Movie Explorer"),
plotly::plotlyOutput(ns("scatter"), height = "500px"),
shiny::textOutput(ns("n_movies"))
)
}Server
mod_plot_server(id, movies, filters) takes two
reactive arguments - the filtered data frame and the filter
selections (passed in from the parent server).
mod_plot_server <- function(id, movies, filters) {
shiny::moduleServer(id, function(input, output, session) {
output$scatter <- plotly::renderPlotly({
m <- movies()
f <- filters()
xvar_name <- names(axis_vars)[axis_vars == f$xvar]
yvar_name <- names(axis_vars)[axis_vars == f$yvar]
p <- ggplot2::ggplot(
m,
ggplot2::aes(
x = .data[[f$xvar]], y = .data[[f$yvar]],
color = has_oscar,
text = paste0("<b>", Title, "</b><br>", Year, "<br>$",
format(BoxOffice, big.mark = ",", scientific = FALSE))
)
) +
ggplot2::geom_point(alpha = 0.4, size = 2) +
ggplot2::scale_color_manual(
name = "Won Oscar",
values = c("Yes" = "orange", "No" = "#aaaaaa")
) +
ggplot2::labs(x = xvar_name, y = yvar_name) +
ggplot2::theme_minimal()
plotly::ggplotly(p, tooltip = "text")
})
output$n_movies <- shiny::renderText({
paste("Movies selected:", nrow(movies()))
})
})
}ggplot2::aes(.data[[f$xvar]]) is the tidy-eval idiom for
using a string as a column name inside aes().
plotly::ggplotly() converts the ggplot2 object
to an interactive plotly widget, with the text
aesthetic driving the hover tooltip.
Passing reactives as arguments
A key design point here is movies and
filters are passed as reactive objects,
not as their values. The module server calls them
(movies(), filters()) inside render functions,
which means Shiny knows to re-run the render whenever either reactive
invalidates.
# In movies_server():
filters <- mod_filters_server("filters") # reactive list
movies <- shiny::reactive({
f <- filters()
movie_data$filter(reviews = f$reviews, genre = f$genre, ...)
})
mod_plot_server("plot", movies = movies, filters = filters)
# ^^^^^^ ^^^^^^^
# reactive object reactive object
# (not movies()) (not filters())Passing the reactive itself (not its current value) lets the plot module subscribe to invalidation events correctly.
Testing mod_plot_server
md <- MovieData$new(db_path)
on.exit(md$disconnect())
movies_r <- shiny::reactive(md$filter(genre = "Drama"))
filters_r <- shiny::reactive(list(xvar = "Meter", yvar = "Reviews"))
shiny::testServer(
mod_plot_server,
args = list(movies = movies_r, filters = filters_r),
{
n <- nrow(md$filter(genre = "Drama"))
expect_equal(output$n_movies, paste("Movies selected:", n))
}
)How the modules connect in movies_server()
movies_server <- function(input, output, session) {
movie_data <- MovieData$new(
system.file("extdata/movies.db", package = "movexplR6")
)
shiny::onStop(function() movie_data$disconnect())
# mod_filters_server returns a reactive list
filters <- mod_filters_server("filters")
# movies() is derived from the reactive list
movies <- shiny::reactive({
f <- filters()
movie_data$filter(
reviews = f$reviews,
oscars = f$oscars,
year = f$year,
boxoffice = f$boxoffice,
genre = f$genre,
director = f$director,
cast = f$cast
)
})
# both reactives are handed to the plot module
mod_plot_server("plot", movies, filters)
}The server function’s only job is wiring: it creates the
MovieData object, calls mod_filters_server()
to get the filters reactive, derives movies
from it, then passes both to mod_plot_server(). No output
rendering happens here (rendering is fully delegated to the
modules).