Modules: cómo escalar más allá de 200 líneas
¿Cuándo modularizar?
Toda app Shiny pequeña empieza siendo legible. Después de añadir cinco gráficos, tres tablas, cuatro filtros y dos botones de exportación, sin estructura llega un momento donde:
servertiene 300 líneas y no encuentras nada.- Repites la misma lógica en tres sitios (un mismo gráfico que aparece en dos pestañas).
- Cambiar un input rompe outputs en sitios inesperados.
- Otro desarrollador no puede tocar el código sin pedirte ayuda.
Modules resuelven esto: cada módulo es una pieza autocontenida con su propia UI y server, reutilizable, testeable independientemente.
La regla operativa: modulariza antes de pensar que lo necesitas. Casi todo el mundo lo deja para demasiado tarde y termina con una app monolítica que duele refactorizar. Si tu app va a tener más de 200 líneas o más de un usuario tocando el código, módulos.
La unidad básica: par UI/server
Un módulo es un par de funciones:
# Función UI del módulo
graficoUI <- function(id, titulo = "Gráfico") {
ns <- NS(id)
card(
card_header(titulo),
selectInput(ns("variable"), "Variable:", choices = NULL),
plotOutput(ns("grafico"))
)
}
# Función server del módulo
graficoServer <- function(id, datos) {
moduleServer(id, function(input, output, session) {
observe({
updateSelectInput(session, "variable",
choices = names(datos()))
})
output$grafico <- renderPlot({
req(input$variable)
hist(datos()[[input$variable]], main = input$variable)
})
})
}Y en la app principal:
ui <- page_sidebar(
title = "Mi dashboard",
graficoUI("hist1", "Histograma A"),
graficoUI("hist2", "Histograma B")
)
server <- function(input, output, session) {
datos_reactivo <- reactive({ mtcars })
graficoServer("hist1", datos_reactivo)
graficoServer("hist2", datos_reactivo)
}Resultado: dos histogramas independientes, cada uno con su propio selector. Si los datos cambian, los dos se actualizan. Cada uno mantiene su estado por separado.
El detalle clave: los dos histogramas usan el mismo código de módulo pero tienen IDs distintos ("hist1" y "hist2"). Sus inputs no chocan. Sus outputs no chocan. Es encapsulación real.
moduleServer() y namespaces
¿Cómo evita Shiny que input$variable del módulo hist1 no choque con el del módulo hist2? Con namespaces.
Cuando llamas graficoUI("hist1", ...), todos los IDs dentro del UI se prefijan automáticamente con "hist1-":
ns("variable")→"hist1-variable"para el primer histograma.ns("variable")→"hist2-variable"para el segundo.
NS(id) es la función generadora de namespace que usas dentro de la UI del módulo. La función ns() que devuelve es lo que aplicas a cada inputId y outputId.
moduleServer(id, function(input, output, session) { ... }) crea el server del módulo con su propio scope reactivo:
- Dentro del módulo,
input$variableaccede al input local (con el namespace ya aplicado automáticamente). - Desde fuera del módulo,
input$variableya no existe, está aislado.
Esto es lo que hace que dos instancias del mismo módulo no se pisen entre sí.
NS() y ns() para prefijar IDs
La regla mental:
- En la UI del módulo: declara
ns <- NS(id)al inicio. Usans("nombre")en cadainputIdyoutputId. - En el server del módulo: NO hace falta
ns(),moduleServer()lo maneja automáticamente. Accedes coninput$nombredirectamente. - Cuando el server del módulo necesita generar UI dinámica (
renderUI,insertUI,showModal): vuelve a hacer faltans(), esta vez accesible víasession$ns.
graficoServer <- function(id, datos) {
moduleServer(id, function(input, output, session) {
ns <- session$ns # disponible aquí si la necesitas
output$ui_dinamica <- renderUI({
# IDs dentro de UI generada deben llevar ns()
sliderInput(ns("slider_dinamico"), "Rango:", min = 0, max = 100, value = 50)
})
observe({
message("Slider: ", input$slider_dinamico)
})
})
}session$ns es la única forma de prefijar IDs correctamente cuando los generas desde server. Olvidarlo es la causa número uno de bugs en módulos.
Comunicación entre módulos
Los módulos se comunican por reactives que pasas como argumentos:
Pasar datos al módulo
graficoServer <- function(id, datos) { # datos es un reactive()
moduleServer(id, function(input, output, session) {
output$grafico <- renderPlot({
hist(datos()[[input$variable]]) # ojo: datos() con paréntesis
})
})
}
# En el server principal:
datos_filtrados <- reactive({
mtcars |> filter(cyl == input$cyl_filter)
})
graficoServer("grafico_principal", datos_filtrados)Pasa el reactive, NO el valor. graficoServer("id", datos_filtrados) pasa el reactive entero. Dentro del módulo accedes con datos(). Si pasas datos_filtrados() (con paréntesis), pasas el valor concreto en ese momento, pierdes la reactividad.
Devolver datos del módulo
Un módulo puede devolver un reactive para que el server principal lo use:
selectorServer <- function(id, datos) {
moduleServer(id, function(input, output, session) {
# Devuelve un reactive
reactive({
datos() |> filter(cyl == input$cyl)
})
})
}
# En server principal:
datos_filtrados <- selectorServer("selector", reactive(mtcars))
graficoServer("grafico", datos_filtrados)El módulo selector filtra. El módulo grafico consume el resultado filtrado. Componibilidad real, el resultado de un módulo alimenta al siguiente.
callModule() vs moduleServer(): legado vs moderno
Antes de Shiny 1.5 (2020), los módulos se llamaban con callModule(). Si lees código viejo verás:
# Patrón antiguo (legacy)
graficoServer <- function(input, output, session, datos) {
output$grafico <- renderPlot({ ... })
}
callModule(graficoServer, "hist1", datos = datos_reactivo)Versus el moderno con moduleServer():
# Patrón moderno (recomendado)
graficoServer <- function(id, datos) {
moduleServer(id, function(input, output, session) {
output$grafico <- renderPlot({ ... })
})
}
graficoServer("hist1", datos_reactivo)Diferencias prácticas:
moduleServer()envuelvefunction(input, output, session)dentro, separando la lógica del boilerplate de Shiny. Más limpio.- Llamada:
graficoServer("id", ...)se ve como cualquier función R normal. - Argumentos: con
callModule(), los argumentos extra van encallModule(...). ConmoduleServer(), son argumentos normales de la función externa.
Para código nuevo: usa moduleServer(). Para código heredado: déjalo, no hay urgencia.
Patrón completo con varios módulos
Ejemplo con tres módulos que se comunican:
# Módulo 1: filtro
filtroUI <- function(id) {
ns <- NS(id)
tagList(
selectInput(ns("region"), "Región:", choices = NULL),
sliderInput(ns("año"), "Año:", min = 2020, max = 2024, value = 2024)
)
}
filtroServer <- function(id, datos_raw) {
moduleServer(id, function(input, output, session) {
observe({
updateSelectInput(session, "region",
choices = unique(datos_raw()$region))
})
reactive({
req(input$region)
datos_raw() |>
filter(region == input$region, año == input$año)
})
})
}
# Módulo 2: KPIs
kpisUI <- function(id) {
ns <- NS(id)
layout_columns(
value_box("Total", textOutput(ns("total"))),
value_box("Media", textOutput(ns("media"))),
value_box("N", textOutput(ns("n")))
)
}
kpisServer <- function(id, datos) {
moduleServer(id, function(input, output, session) {
output$total <- renderText(scales::comma(sum(datos()$ventas)))
output$media <- renderText(round(mean(datos()$ventas), 2))
output$n <- renderText(nrow(datos()))
})
}
# Módulo 3: gráfico
graficoUI <- function(id) {
ns <- NS(id)
plotOutput(ns("plot"))
}
graficoServer <- function(id, datos) {
moduleServer(id, function(input, output, session) {
output$plot <- renderPlot({
ggplot(datos(), aes(fecha, ventas)) + geom_line()
})
})
}
# App principal
ui <- page_sidebar(
sidebar = sidebar(filtroUI("filtro")),
kpisUI("kpis"),
graficoUI("grafico")
)
server <- function(input, output, session) {
datos_raw <- reactive({ read_csv("ventas.csv") })
datos_filtrados <- filtroServer("filtro", datos_raw)
kpisServer("kpis", datos_filtrados)
graficoServer("grafico", datos_filtrados)
}
shinyApp(ui, server)Resultado: una app organizada donde cada pieza tiene una responsabilidad y se compone con las demás. Refactorizar cualquier módulo no afecta a los otros. Añadir un nuevo gráfico es una línea más.
Trampas habituales
- Olvidar
ns()en la UI del módulo. Sinns()en losinputId, todos los módulos colisionan, ambos tienen el mismo input. Diagnóstico: la app “funciona pero cosas raras”, los inputs se contaminan entre módulos. - Pasar valores en lugar de reactives.
graficoServer("id", datos())pasa el valor en el momento de la llamada, perdiendo reactividad.graficoServer("id", datos)(sin paréntesis) pasa el reactive entero. - Anidar módulos sin propagar el namespace. Si un módulo crea otro módulo dentro, los IDs internos necesitan estar anidados también.
ns(NS("hijo")("input_id"))es la forma manual.moduleServerlo gestiona si pasas IDs comoid. - Modularizar desde el día uno con apps de 50 líneas. Otro extremo del problema. Para una app pequeña, el overhead de módulos no compensa. La regla: modulariza cuando empieces a copiar/pegar lógica entre
output$xyoutput$y.
En la siguiente entrega
Has aprendido a estructurar apps grandes. La siguiente pieza es el estado persistente, cómo guardar el estado de la app para que el usuario pueda compartir una URL que restaura todo, o reabrir su sesión donde la dejó. Lo siguiente.