Modules: cómo escalar más allá de 200 líneas

r
shiny
El sistema de modules de Shiny. moduleServer() y la pareja UI/server, namespaces con NS() y ns(), comunicación entre módulos vía reactives, y por qué la regla ‘modulariza después’ casi siempre es demasiado tarde.

¿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:

  • server tiene 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$variable accede al input local (con el namespace ya aplicado automáticamente).
  • Desde fuera del módulo, input$variable ya 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. Usa ns("nombre") en cada inputId y outputId.
  • En el server del módulo: NO hace falta ns(), moduleServer() lo maneja automáticamente. Accedes con input$nombre directamente.
  • Cuando el server del módulo necesita generar UI dinámica (renderUI, insertUI, showModal): vuelve a hacer falta ns(), esta vez accesible vía session$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() envuelve function(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 en callModule(...). Con moduleServer(), 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. Sin ns() en los inputId, 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. moduleServer lo gestiona si pasas IDs como id.
  • 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$x y output$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.