Skills · R

Shiny shared state

A store every module can reach with no arguments, so a value picked in one module is read reactively in another without a chain of parameters through every level.

What it does

  • Keeps one reactiveValues on the root session's userData, created once at the top of the server and fetched anywhere by calling shared_state(session).
  • Reactive get, plain set, a non-reactive peek for event handlers, and a keys list; nothing global, so two apps in one process never share state and testServer still works.
  • Resolves a module session to its root session, so the top level and every module see the same store.
  • Includes a runnable two-module demo: a picker writes the team, a reader shows it, and neither is handed the other.

How to use it

Source the file, create the store once in the main server with its initial values, and in each module call shared_state(session) instead of threading reactives through arguments.

Needs: shiny. License: MIT. Written from the method the session teaches; the instructor's own materials come with the recording.

The session it came from

The No-Look Pass. The skill is the method from that session, packaged so you can use it before you have watched it; the session is where the reasoning lives. Every skill in the library names its session the same way.

The file

shared_state.R · 86 lines

# shared_state.R
#
# Shared state between Shiny modules without passing a chain of arguments through every level:
# the pattern from the AthlyticZ session "The No-Look Pass" (Veerle Eeftink-van Leemput), as one
# small file you can source() into any app.
#
#   source("shared_state.R")
#
#   server <- function(input, output, session) {
#     store <- shared_state(session, team = NULL, season = 2026)   # create once, at the top
#     pickerServer("pick")                                         # writes store$team
#     headToHeadServer("h2h")                                      # reads  store$team
#   }
#
#   pickerServer <- function(id) moduleServer(id, function(input, output, session) {
#     store <- shared_state(session)                                # fetch it anywhere, no arguments
#     observeEvent(input$team, store$set("team", input$team))
#   })
#
#   headToHeadServer <- function(id) moduleServer(id, function(input, output, session) {
#     store <- shared_state(session)
#     output$title <- renderText(paste("Head to head:", store$get("team")))   # reactive read
#   })
#
# How it works, in two sentences. The session object is one shared object for the whole app, and
# session$userData is a plain environment on it that every module can reach without being handed
# anything; the store keeps a reactiveValues() there, so a read inside a reactive context
# re-runs when the value changes, and the top-level and module sessions resolve to the same
# root session so they see the same store. Nothing is global, two apps in one process never
# share state, and testServer() works because each test gets its own session.
#
# What it does not do. It is not namespaced: a key is one key for the whole app, so name keys
# for what they are ("team", "season"), not for the module that writes them. If two modules
# write the same key you have a design problem, not a bug in this file.
#
# License: MIT. Written for athlyticz.com/skills from the method the session teaches; the
# session's own app and code come with the recording.

library(shiny)

shared_state <- function(session = shiny::getDefaultReactiveDomain(), ...) {
  stopifnot(!is.null(session))
  root <- session
  while (!is.null(root$rootScope) && !identical(root$rootScope(), root)) root <- root$rootScope()
  if (is.null(root$userData$.shared_state)) {
    init <- list(...)
    root$userData$.shared_state <- do.call(shiny::reactiveValues, init)
  } else if (length(list(...))) {
    warning("shared_state() already exists on this session; initial values ignored", call. = FALSE)
  }
  rv <- root$userData$.shared_state
  structure(list(
    get  = function(key) rv[[key]],                     # reactive read: call inside a reactive context
    set  = function(key, value) { rv[[key]] <- value; invisible(value) },
    peek = function(key) shiny::isolate(rv[[key]]),     # non-reactive read, for event handlers
    keys = function() shiny::isolate(names(shiny::reactiveValuesToList(rv))),
    rv   = rv                                           # the reactiveValues itself, if you want it
  ), class = "shared_state")
}

print.shared_state <- function(x, ...) {
  cat("<shared_state>", paste(x$keys(), collapse = ", "), "\n")
  invisible(x)
}

# A minimal runnable example. Run with: shiny::runApp(shared_state_demo())
shared_state_demo <- function() {
  pickerUI <- function(id) selectInput(NS(id, "team"), "Team", c("Phoenix", "Seattle", "Las Vegas"))
  pickerServer <- function(id) moduleServer(id, function(input, output, session) {
    store <- shared_state(session)
    observeEvent(input$team, store$set("team", input$team), ignoreInit = FALSE)
  })
  readerUI <- function(id) h3(textOutput(NS(id, "title")))
  readerServer <- function(id) moduleServer(id, function(input, output, session) {
    store <- shared_state(session)
    output$title <- renderText(paste("Selected without any arguments:", store$get("team")))
  })
  shinyApp(
    ui = fluidPage(pickerUI("pick"), readerUI("read")),
    server = function(input, output, session) {
      shared_state(session, team = "Phoenix")
      pickerServer("pick")
      readerServer("read")
    }
  )
}
Keep up

One email when something new arrives.

First name and email. One message when a session recording, a skill, a cheat sheet or a posting worth your time arrives here, and no other mail. Replies reach a person.