Reactividad: el corazón conceptual

r
shiny
El grafo reactivo, reactive() vs observe() vs observeEvent() vs eventReactive(). Lazy evaluation, side effects vs values, isolate() para romper la cadena y debug con reactlog.

El grafo reactivo: el modelo mental

Antes de tocar funciones, hay que entender cómo piensa Shiny.

Shiny construye internamente un grafo dirigido donde:

  • Productores reactivos (input$x, reactive({...})) son nodos que producen valores.
  • Consumidores reactivos (output$y, observe({...})) son nodos que consumen valores.
  • Aristas se crean automáticamente: si un consumidor lee a un productor, Shiny lo anota.

Cuando un productor cambia (por ejemplo, el usuario mueve un slider y input$n se actualiza), Shiny invalida todos los consumidores que dependen de él. Los consumidores se vuelven a ejecutar, pero solo los necesarios.

Esto es lo que distingue a Shiny de Streamlit. Streamlit reejecuta el script entero. Shiny ejecuta solo los nodos del grafo afectados por el cambio.

reactive(): cálculo perezoso reutilizable

server <- function(input, output, session) {
  # Compartir cálculo entre múltiples outputs
  datos <- reactive({
    set.seed(input$seed)
    rnorm(input$n)
  })

  output$grafico <- renderPlot({
    hist(datos())
  })

  output$resumen <- renderPrint({
    summary(datos())
  })
}

Lectura:

  • datos <- reactive({...}) declara un valor reactivo. La expresión no se ejecuta inmediatamente.
  • datos() (con paréntesis) ejecuta la expresión y devuelve el valor. Sí, los reactive() se acceden como funciones.
  • reactive() cachea: si input$n y input$seed no han cambiado, datos() devuelve el valor memoizado sin recalcular.

Tres propiedades clave de reactive():

  1. Lazy: solo se ejecuta cuando alguien lo invoca con datos(). Si ningún output ni observer lo usa, nunca corre.
  2. Cacheado: dentro de una invalidación, solo se ejecuta la primera vez. Las llamadas siguientes devuelven el valor cacheado.
  3. Devuelve valor: produce un resultado que otros consumidores usan. No tiene efectos secundarios.

observe(): efecto secundario reactivo

A veces no quieres un valor, quieres hacer algo cuando un input cambia:

server <- function(input, output, session) {
  observe({
    message("El usuario seleccionó: ", input$region)
  })
}

Diferencias con reactive():

  • No es lazy: corre inmediatamente cuando se invalida, sin que nadie lo pida.
  • No tiene cacheo: siempre se ejecuta entero cuando se invalida.
  • No devuelve valor utilizable: su propósito es el side effect (log, escribir DB, actualizar UI vía updateSelectInput, etc.).

Casos típicos:

  • Escribir a log o consola para debug.
  • Actualizar inputs en base a otros (updateSelectInput() cuando cambia el dataset).
  • Escribir cambios a una base de datos.
  • Mandar emails, lanzar notificaciones.

observeEvent(): explícito sobre el trigger

Cuando quieres responder a un evento específico (un click, un cambio concreto), observeEvent() es más claro que observe():

server <- function(input, output, session) {
  observeEvent(input$recalcular, {
    showNotification("Recalculando…")
    # Aquí va la lógica que solo corre al pulsar el botón
  })
}

Conceptualmente: “observa input$recalcular. Cuando cambie, ejecuta esto”.

La diferencia con observe():

  • observe() reacciona a todos los inputs que lee en su cuerpo.
  • observeEvent() reacciona solo al primer argumento (el “evento”). Los otros valores que el cuerpo lee se acceden con isolate() implícitamente, no disparan reejecución.

Ejemplo donde la diferencia importa:

# Con observe(): se ejecuta cuando CUALQUIERA de los dos cambia
observe({
  if (input$boton > 0) {
    procesa(input$valor)
  }
})

# Con observeEvent(): solo se ejecuta cuando se PULSA el botón
observeEvent(input$boton, {
  procesa(input$valor)
})

Si el usuario cambia input$valor sin pulsar el botón, la primera versión se ejecuta y la segunda no. La segunda es probablemente lo que querías, “corre cuando el usuario decida explícitamente”.

observeEvent() es el patrón natural para botones (actionButton).

eventReactive(): valor solo cuando dispara

A veces quieres un valor que solo se actualiza cuando un evento dispara:

server <- function(input, output, session) {
  resultado <- eventReactive(input$calcular, {
    # Solo se recalcula cuando el botón se pulsa
    set.seed(input$seed)
    rnorm(input$n)
  })

  output$grafico <- renderPlot({
    hist(resultado())
  })
}

eventReactive() es a reactive() lo que observeEvent() es a observe():

  • reactive() recalcula cuando cualquier dependencia cambia.
  • eventReactive() recalcula solo cuando el evento dispara.

Caso de uso típico: cálculos pesados que el usuario activa explícitamente. “No quiero rehacer el modelo cada vez que el usuario mueve un slider. Solo cuando pulse el botón de Entrenar”.

Resumen: matriz de decisión

Necesitas… Lazy? Devuelve valor? Usa
Cálculo reutilizable que se actualiza con sus inputs reactive()
Hacer algo cuando los inputs cambian No No observe()
Hacer algo cuando UN evento específico dispara No No observeEvent()
Cálculo que se actualiza solo cuando UN evento dispara No (eager al disparar) eventReactive()

Regla mental:

  • ¿Necesitas un valor o un efecto? → reactive/eventReactive vs observe/observeEvent.
  • ¿Reacciona a todo lo que lee, o solo a un trigger? → forma básica vs forma event*.

isolate(): romper la dependencia

A veces quieres leer un valor reactivo sin que su cambio dispare reejecución. isolate() lo desactiva temporalmente:

observe({
  # Reacciona cuando cambia input$x, pero NO cuando cambia input$y
  resultado <- input$x + isolate(input$y)
  message("Resultado: ", resultado)
})

Útil para “observa A pero usa el último valor de B”. Es lo que hace observeEvent internamente, solo el primer argumento es la dependencia. El resto va aislado por defecto.

Para usos avanzados, isolate() permite controlar exactamente qué dispara qué, al precio de más complejidad. Empieza por preferir observeEvent() o eventReactive(). Usa isolate() solo cuando la lógica lo justifique.

Lazy evaluation: nadie corre nada hasta que se necesita

Una propiedad importante: un reactive() que nunca se invoca, nunca se ejecuta.

server <- function(input, output, session) {
  caro <- reactive({
    # Esta computación carísima nunca corre si nadie usa caro()
    procesa_lento(input$datos)
  })

  output$tabla <- renderTable({
    if (input$modo == "rapido") {
      head(input$datos)
    } else {
      caro()    # solo aquí dispara la computación
    }
  })
}

Si el usuario nunca cambia input$modo a algo distinto de "rapido", caro() nunca se ejecuta. Esto permite optimizar estructurando los reactive() para que el coste solo se pague cuando se necesita.

observe() y observeEvent() son eager, corren cuando se invalidan, las llame alguien o no. Por eso son para efectos, no para valores.

Debug con reactlog

Cuando el grafo reactivo se complica, visualizarlo ayuda. reactlog lo hace:

library(reactlog)
options(shiny.reactlog = TRUE)

# Ejecuta tu app
runApp()

# Mientras la app está corriendo, en otra consola R:
reactlog_show()

Esto abre un visualizador interactivo del grafo. Cada nodo es un reactive/observer/output. Las aristas muestran dependencias. Puedes ver qué se invalidó y por qué con cada cambio.

Casi siempre lo necesitarás cuando tu app “no recalcula lo que esperabas” o “recalcula demasiado”. Es la herramienta diagnóstica principal.

Trampas habituales

  • Llamar reactive sin paréntesis al usarlo. datos es el reactive. datos() es su valor. Si escribes hist(datos), da error porque datos es una función, no un vector.
  • reactive() en lugar de eventReactive() con botones. Si declaras datos <- reactive({...}) y el usuario quiere control explícito, cualquier cambio en input$n recalcula, no esperaría al botón. Usa eventReactive().
  • observe() para producir valores que otros leen. observe() no devuelve nada utilizable. Si quieres compartir un valor, es reactive(). Confundir esto es uno de los errores conceptuales más comunes.
  • Dependencias accidentales por leer input$x dentro de un reactive. Cualquier input$x leído invalida el reactive. Si quieres “leer pero no reaccionar”, usa isolate(). La regla: lo que lees, te ata.

En la siguiente entrega

Has aprendido el modelo reactivo, el concepto más importante de Shiny. La siguiente entrega abre el Bloque 2 con layouts modernos: cómo organizar la UI con bslib (Bootstrap 5), cards, sidebars y navbars. Lo siguiente.