Caso completo: dashboard con módulos, plotly y deploy
El cierre de la ruta
Has completado 11 tutoriales sobre Shiny. Esta entrega no introduce conceptos nuevos: junta todo en un dashboard real, desde la primera línea hasta la app desplegada en shinyapps.io.
Reusamos los datos de nycflights13, los mismos vuelos de NYC 2013 que vimos en la Ruta 1 y la Ruta 2. El cambio: ahora son interactivos.
El proyecto
“Construir un dashboard que un analista pueda usar para explorar la puntualidad de aerolíneas en NYC, con filtros, KPIs, gráficos interactivos y tabla descargable.”
Estructura del proyecto
dashboard-vuelos/
├── app.R
├── R/
│ ├── mod_filtros.R
│ ├── mod_kpis.R
│ ├── mod_grafico.R
│ └── mod_tabla.R
├── www/
│ └── styles.css # opcional, custom CSS
└── renv.lock # dependencias fijadas
Shiny carga automáticamente cualquier .R en R/, es donde van los módulos. app.R es la composición final.
Paso 1: Cargar paquetes y datos
# R/setup.R (también en app.R, lo que prefieras)
library(shiny)
library(bslib)
library(dplyr)
library(ggplot2)
library(plotly)
library(reactable)
library(scales)
library(nycflights13)
# Pre-procesar una vez al iniciar el server (fuera de session)
vuelos <- flights |>
filter(!is.na(dep_delay)) |>
mutate(fecha = as.Date(paste(year, month, day, sep = "-"))) |>
left_join(airlines, by = "carrier") |>
rename(aerolinea = name) |>
select(fecha, month, aerolinea, origin, dep_delay, arr_delay, distance)Carga fuera del server → corre una vez al arrancar, no por usuario.
Paso 2: Módulo de filtros (R/mod_filtros.R)
filtros_ui <- function(id) {
ns <- NS(id)
tagList(
selectInput(ns("aerolinea"), "Aerolínea:",
choices = c("Todas" = "todas"), multiple = FALSE),
selectInput(ns("origen"), "Aeropuerto de origen:",
choices = c("Todos" = "todos", "JFK", "LGA", "EWR")),
sliderInput(ns("mes"), "Rango de meses:",
min = 1, max = 12, value = c(1, 12), step = 1)
)
}
filtros_server <- function(id, datos) {
moduleServer(id, function(input, output, session) {
# Poblar el selector de aerolíneas dinámicamente
observe({
aerolineas <- c("Todas" = "todas", sort(unique(datos$aerolinea)))
updateSelectInput(session, "aerolinea", choices = aerolineas)
})
# Reactive con los datos filtrados
reactive({
d <- datos
if (input$aerolinea != "todas") d <- d |> filter(aerolinea == input$aerolinea)
if (input$origen != "todos") d <- d |> filter(origin == input$origen)
d |> filter(between(month, input$mes[1], input$mes[2]))
})
})
}Tres filtros + un reactive que devuelve el dataset filtrado. El módulo encapsula toda la lógica del filtrado.
Paso 3: Módulo de KPIs (R/mod_kpis.R)
kpis_ui <- function(id) {
ns <- NS(id)
layout_columns(
value_box(
title = "Vuelos",
value = textOutput(ns("n_vuelos")),
theme = "primary"
),
value_box(
title = "Retraso mediano",
value = textOutput(ns("retraso_mediano")),
theme = "warning"
),
value_box(
title = "% a tiempo",
value = textOutput(ns("pct_tiempo")),
theme = "success"
)
)
}
kpis_server <- function(id, datos_filt) {
moduleServer(id, function(input, output, session) {
output$n_vuelos <- renderText({
comma(nrow(datos_filt()))
})
output$retraso_mediano <- renderText({
paste0(round(median(datos_filt()$dep_delay), 1), " min")
})
output$pct_tiempo <- renderText({
pct <- mean(datos_filt()$dep_delay <= 0) * 100
paste0(round(pct, 1), "%")
})
})
}Tres value_box clásicos. El módulo recibe los datos filtrados como reactive() y calcula los KPIs.
Paso 4: Módulo de gráfico interactivo (R/mod_grafico.R)
grafico_ui <- function(id) {
ns <- NS(id)
card(
card_header("Retraso medio por aerolínea y mes"),
plotlyOutput(ns("grafico"))
)
}
grafico_server <- function(id, datos_filt) {
moduleServer(id, function(input, output, session) {
output$grafico <- renderPlotly({
validate(
need(nrow(datos_filt()) > 0, "No hay datos con los filtros actuales.")
)
resumen <- datos_filt() |>
group_by(aerolinea, month) |>
summarise(
retraso_medio = mean(dep_delay, na.rm = TRUE),
n = n(),
.groups = "drop"
) |>
filter(n >= 30) # ignorar combinaciones con pocos vuelos
p <- ggplot(resumen, aes(month, retraso_medio, color = aerolinea)) +
geom_line(linewidth = 0.6) +
geom_point(size = 1.5) +
scale_x_continuous(breaks = 1:12, labels = month.abb) +
scale_y_continuous(labels = label_number(suffix = " min")) +
scale_color_viridis_d(option = "viridis", end = 0.9) +
labs(x = NULL, y = NULL, color = NULL) +
theme_minimal()
ggplotly(p) |>
config(displayModeBar = FALSE)
})
})
}validate() muestra mensaje si los filtros dejan cero filas. ggplotly() añade interactividad gratis. config(displayModeBar = FALSE) oculta la barra de plotly para una UI más limpia.
Paso 5: Módulo de tabla con descarga (R/mod_tabla.R)
tabla_ui <- function(id) {
ns <- NS(id)
card(
card_header(
"Vuelos detallados",
class = "d-flex justify-content-between",
downloadButton(ns("descargar"), "Descargar CSV", class = "btn-sm")
),
reactableOutput(ns("tabla"))
)
}
tabla_server <- function(id, datos_filt) {
moduleServer(id, function(input, output, session) {
output$tabla <- renderReactable({
reactable(
datos_filt(),
defaultPageSize = 15,
searchable = TRUE,
filterable = TRUE,
striped = TRUE,
highlight = TRUE,
columns = list(
fecha = colDef(name = "Fecha"),
aerolinea = colDef(name = "Aerolínea"),
origin = colDef(name = "Origen", align = "center"),
dep_delay = colDef(name = "Retraso salida", format = colFormat(suffix = " min")),
arr_delay = colDef(name = "Retraso llegada", format = colFormat(suffix = " min")),
distance = colDef(name = "Distancia", format = colFormat(suffix = " mi")),
month = colDef(show = FALSE)
)
)
})
output$descargar <- downloadHandler(
filename = function() {
paste0("vuelos-", format(Sys.Date(), "%Y-%m-%d"), ".csv")
},
content = function(file) {
write.csv(datos_filt(), file, row.names = FALSE)
}
)
})
}reactable con filtros por columna + búsqueda global. downloadHandler para que el usuario descargue los datos filtrados a CSV, patrón estándar.
Paso 6: Composición en app.R
ui <- page_sidebar(
title = "Puntualidad de vuelos — NYC 2013",
theme = bs_theme(
version = 5,
bg = "#FAF9F6", fg = "#2A2A2A",
primary = "#5F8575",
base_font = font_google("Inter Tight"),
heading_font = font_google("Newsreader")
),
sidebar = sidebar(
title = "Filtros",
filtros_ui("filtros")
),
kpis_ui("kpis"),
grafico_ui("grafico"),
tabla_ui("tabla")
)
server <- function(input, output, session) {
datos_filtrados <- filtros_server("filtros", vuelos)
kpis_server("kpis", datos_filtrados)
grafico_server("grafico", datos_filtrados)
tabla_server("tabla", datos_filtrados)
}
shinyApp(ui, server)El server principal son cuatro líneas. Todo lo demás vive en módulos. Cuando crezca, añadir un módulo es una línea más. Refactorizar uno no toca los otros.
Esto es lo que hace módulos esencial: el app.R queda declarativo, describe la composición, no la lógica.
Paso 7: Deployment con renv
Antes de subir, fija las dependencias:
# Una vez en el proyecto
renv::init() # crea renv.lock con tus paquetes actuales
# Cuando añadas paquetes nuevos
renv::snapshot() # actualiza renv.lockrenv.lock registra las versiones exactas. Al desplegar, shinyapps.io (y cualquier otro target) las instalará para reproducir tu entorno.
Deployment a shinyapps.io:
rsconnect::deployApp(
appDir = ".",
appName = "puntualidad-nyc",
appTitle = "Puntualidad de vuelos NYC 2013",
forceUpdate = TRUE
)URL resultante: https://tu_usuario.shinyapps.io/puntualidad-nyc/.
Resumen visual de la arquitectura
┌────────────────┐
│ filtros_server │
│ (datos) │
└───────┬────────┘
│ reactive(datos_filtrados)
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ kpis_server │ │ grafico_server │ │ tabla_server│
└─────────────┘ └─────────────┘ └─────────────┘
Un módulo de filtros produce un reactive. Tres módulos consumidores lo leen. Cero acoplamiento entre los consumidores, cualquiera se puede quitar, cambiar o duplicar sin afectar a los demás.
Lo que has hecho
En este caso completo has aplicado los once tutoriales anteriores en orden:
- Decisión inicial de usar Shiny (Ruta 5 - tutorial 1).
- Estructura del proyecto con
R/y carga única de datos (tutorial 2). - Inputs (filtros) y outputs (value_box, plotly, reactable) (tutorial 3).
- Reactividad bien estructurada, un solo
reactive()central alimentando consumidores (tutorial 4). - UI moderna con
bslib:page_sidebar,card,value_box,bs_themecon tipografía editorial (tutorial 5). - Tabla con
reactable+ filtros por columna + descarga CSV (tutorial 6). - Gráfico interactivo con
ggplotly(tutorial 7). validate()para mensajes cuando los filtros dejan cero filas (tutorial 8).- Cuatro módulos independientes con namespaces correctos (tutorial 9).
- (No usamos bookmarking explícito, pero la app está lista para añadirlo en un paso.)
- Deployment a shinyapps.io con
renvpara reproducibilidad (tutorial 11).
Y un app.R declarativo de 25 líneas que cualquier desarrollador puede leer y entender en un minuto.
¿Quieres ir más a fondo?
El libro Shiny para producción: del prototipo a la app desplegada (en preparación) desarrolla esta misma materia con:
- Un dashboard corporativo real (no datos académicos).
- Arquitectura con
golem, el framework para apps Shiny en producción. - Testing con
shinytest2. - Autenticación enterprise (SSO via OAuth2).
- Deployment con Docker + ShinyProxy + nginx en VPS.
Has terminado la Ruta 5
Felicidades. Si quieres seguir aprendiendo R, las rutas que naturalmente continúan son:
- Python para análisis de datos: apertura al ecosistema Python.
- Reproducibilidad con Quarto: para que tu trabajo sea ejecutable en otra máquina mañana.
Ambas están en el listado de Rutas.