Estado persistente: bookmarking y reactiveValues

r
shiny
Bookmarking para URLs que restauran estado, modos URL vs server. reactiveValues() para estado mutable que no es un input. Hooks onBookmark/onRestore para estado custom y persistencia entre sesiones.

¿Qué quieres persistir?

Shiny por defecto es stateless por sesión: el usuario abre la app, la app empieza desde cero. Refrescas el navegador y pierdes todo el estado.

A veces esto basta. A veces no:

  • “Compárteme el dashboard con estos filtros aplicados”: necesitas URL que restaura el estado.
  • “Sigo el análisis donde lo dejé ayer”: necesitas persistencia entre sesiones.
  • “El usuario añade entradas y se acumulan en una lista”: necesitas estado mutable dentro de la sesión.

Tres mecanismos distintos para tres necesidades distintas. Vamos uno por uno.

Bookmarking: URLs que restauran estado

Bookmarking convierte el estado actual de la app en una URL larga que, abierta de nuevo, restaura exactamente lo mismo. El usuario hace click en “Guardar”, copia la URL, la comparte, el receptor abre y ve la app como estaba.

Activarlo es una línea:

ui <- function(request) {
  page_sidebar(
    title = "Mi app",
    sidebar = sidebar(
      sliderInput("n", "Muestras:", min = 10, max = 1000, value = 100),
      bookmarkButton()
    ),
    plotOutput("grafico")
  )
}

server <- function(input, output, session) {
  output$grafico <- renderPlot({
    hist(rnorm(input$n))
  })
}

enableBookmarking(store = "url")
shinyApp(ui, server)

Dos cambios respecto a una app normal:

  1. ui ahora es una función que recibe request. Esto le permite a Shiny inyectar el estado restaurado.
  2. enableBookmarking(store = "url") antes del shinyApp().

Más bookmarkButton() dentro de la UI, el botón que el usuario pulsa para generar la URL.

Los dos modos: "url" vs "server"

enableBookmarking(store = "url")     # estado codificado en la URL
enableBookmarking(store = "server")  # estado guardado en el servidor, URL solo tiene un id

Modo "url":

  • Estado serializado en query string (?n=100&region=Norte).
  • Funciona sin servidor de almacenamiento.
  • La URL puede crecer mucho si hay muchos inputs.
  • Buena privacidad: el servidor no guarda nada.

Modo "server":

  • Estado guardado en disco del servidor. La URL solo tiene un id (?_state_id_=abc123).
  • URL corta.
  • Requiere persistencia en el host (los shinyapps.io tiene plan dedicado).
  • El estado caduca (configurable).

Para apps con pocos inputs simples, usa "url". Para apps con mucho estado, usa "server" y configura caducidad.

reactiveValues() vs reactive()

reactive() produce un valor derivado de inputs/otros reactives, es read-only para los consumidores, no muta.

reactiveValues() crea un contenedor mutable que el server puede modificar y los consumidores lo leen reactivamente. Es lo que necesitas para estado que no viene de un input.

server <- function(input, output, session) {
  # Contenedor mutable
  estado <- reactiveValues(
    historial = character(0),
    contador  = 0
  )

  # Modificar estado en respuesta a un evento
  observeEvent(input$agregar, {
    estado$historial <- c(estado$historial, input$nuevo)
    estado$contador  <- estado$contador + 1
  })

  # Leer estado reactivamente
  output$lista <- renderUI({
    tags$ul(lapply(estado$historial, tags$li))
  })

  output$total <- renderText({
    paste("Total entradas:", estado$contador)
  })
}

Lo que reactiveValues() te da:

  • Los campos (estado$historial, estado$contador) son mutables.
  • Cualquier lectura es reactiva, modifica estado$historial, los outputs que lo leen se actualizan.
  • Asignación dispara invalidación inmediata.

Casos de uso típicos:

  • Acumular eventos: lista de cosas que el usuario añade en sesión.
  • Estado de pasos: en una app multi-step, qué paso lleva el usuario.
  • Caché temporal: resultado de un cálculo costoso que no quieres recalcular.
  • Comunicación entre módulos: un módulo escribe, otros leen.

La diferencia clave: read-only vs mutable

# reactive(): no se puede mutar
datos <- reactive({ mtcars })
datos$cyl <- 999   # ERROR

# reactiveValues(): sí se puede
estado <- reactiveValues(datos = mtcars)
estado$datos$cyl <- 999   # OK

Regla mental:

  • Si el valor se deriva de otra cosa (filtros, cálculos), usa reactive().
  • Si el valor es estado en sí mismo (lo que el usuario ha hecho, contadores, listas acumuladas), usa reactiveValues().

Confundirlos es uno de los errores más comunes de quien aprende reactividad, usar reactive() cuando necesita reactiveValues() y al revés.

Hooks onBookmark y onRestore

Si tu app tiene estado en reactiveValues(), bookmarking por defecto no lo guarda (solo guarda inputs). Para que sí lo haga:

server <- function(input, output, session) {
  estado <- reactiveValues(historial = character(0))

  # Al guardar bookmark, agrega estado custom
  onBookmark(function(state) {
    state$values$historial <- estado$historial
  })

  # Al restaurar bookmark, lee el estado custom
  onRestore(function(state) {
    estado$historial <- state$values$historial
  })

  # ... resto del server
}

onBookmark() se ejecuta justo antes de generar la URL. state$values es donde guardas lo que quieras serializar.

onRestore() se ejecuta cuando el usuario abre una URL con bookmark. Recibe el state$values guardado y lo aplicas donde corresponda.

Excluir inputs del bookmark

A veces no quieres que ciertos inputs se guarden (tokens, campos sensibles):

server <- function(input, output, session) {
  setBookmarkExclude(c("password", "csrf_token"))
}

setBookmarkExclude() recibe los IDs a excluir. Bookmarking los ignora.

Persistencia real entre sesiones

Bookmarking guarda estado por URL. Para persistencia real entre sesiones (el usuario abre la app mañana y la encuentra como la dejó), necesitas almacenamiento externo:

library(DBI)

server <- function(input, output, session) {
  # ID del usuario (por sesión, por cookie, por login)
  user_id <- session$user %||% "anonymous"

  # Cargar estado al inicio
  estado <- reactiveValues(
    favoritos = cargar_favoritos(user_id)  # función custom contra DB
  )

  # Guardar cuando cambie
  observeEvent(input$agregar_favorito, {
    estado$favoritos <- c(estado$favoritos, input$nuevo)
    guardar_favoritos(user_id, estado$favoritos)  # escribe a DB
  })
}

Las opciones de almacenamiento típicas:

  • DBI + SQLite/Postgres: para apps con varios usuarios.
  • readr::write_csv a un volumen persistente: para apps muy simples.
  • pins (paquete): abstracción amigable para guardar/leer objetos R con versionado.

Para apps en producción con autenticación, normalmente el ID viene de session$user (con Posit Connect) o de un sistema de auth externo. Diseña la persistencia siempre alrededor de un identificador de usuario fiable, nunca por IP o cookie sola.

Patrón completo de ejemplo

App con bookmarking + estado mutable + persistencia entre sesiones:

library(shiny)
library(bslib)

ui <- function(request) {
  page_sidebar(
    title = "Lista de favoritos",
    sidebar = sidebar(
      textInput("nuevo", "Nuevo favorito:"),
      actionButton("agregar", "Añadir", class = "btn-primary"),
      hr(),
      bookmarkButton()
    ),
    card(
      card_header("Tus favoritos"),
      uiOutput("lista"),
      textOutput("total")
    )
  )
}

server <- function(input, output, session) {
  user_id <- session$user %||% "anonymous"

  # Cargar al inicio (mock; en prod sería desde DB)
  estado <- reactiveValues(
    favoritos = readLines("favoritos.txt", warn = FALSE) %||% character(0)
  )

  # Añadir
  observeEvent(input$agregar, {
    req(input$nuevo != "")
    estado$favoritos <- c(estado$favoritos, input$nuevo)
    writeLines(estado$favoritos, "favoritos.txt")
    updateTextInput(session, "nuevo", value = "")
    showNotification("Añadido", type = "message", duration = 2)
  })

  # UI dinámica
  output$lista <- renderUI({
    if (length(estado$favoritos) == 0) {
      em("Sin favoritos aún")
    } else {
      tags$ul(lapply(estado$favoritos, tags$li))
    }
  })

  output$total <- renderText({
    paste("Total:", length(estado$favoritos))
  })

  # Persistir en bookmark
  onBookmark(function(state) {
    state$values$favoritos <- estado$favoritos
  })

  onRestore(function(state) {
    estado$favoritos <- state$values$favoritos
  })
}

enableBookmarking(store = "url")
shinyApp(ui, server)

Tres mecanismos integrados: estado mutable durante la sesión, bookmark URL para compartir, persistencia entre sesiones vía fichero.

Trampas habituales

  • ui como objeto en lugar de función con bookmarking activado. Bookmarking requiere ui <- function(request) { ... }, no ui <- page_sidebar(...). Olvidarlo es la causa número uno de “bookmarking no funciona”.
  • Modificar reactiveValues desde varios sitios sin orden claro. Si tres observeEvent distintos modifican estado$x a la vez, el resultado depende del orden, caos. Centraliza la mutación en un solo sitio.
  • reactive() cuando necesitas reactiveValues(). reactive({ x <- 0; x <- x + 1; x }) no mantiene estado entre invocaciones. Cada llamada empieza de cero. Para acumular, reactiveValues().
  • Persistir información sensible sin cifrado. Si guardas configuración de usuario en disco, no metas tokens, contraseñas o datos personales sin pensarlo. Considera tu modelo de amenaza.

En la siguiente entrega

Has aprendido a gestionar estado. Solo queda el último paso operativo: desplegar la app. Hay varias opciones según privacidad, escala y presupuesto. Lo siguiente.