Sharing data between pages in a multi-page Brochure Shiny application

A breakdown of some approaches for between-page data sharing in a multi-session {brochure} {shiny} application

Author

teo

Published

2025-01-27

During 2024 I had an opportunity to work on an R/Shiny project for a large pharmaceutical company. I was in charge of converting a set of complex Excel workbooks into a {shiny} application. The application had tight integration with two databases, one for data retrieval and another for storing user history, preferences, and results. One of the requirements was that the application works with web-browser buttons (forward, back) and as we know, that is not a feature of {shiny} out of the box.

A {shiny} application by default is single-page and single-session. Meaning when we navigate from one to another “page” in a typical shiny application, some parts of the UI are hidden and others displayed, giving the impression that we went somewhere else. But in fact all of our application’s UI is still loaded in the browser, and its only partially shown. This is why, by default, navigating with browser buttons does not work. There is simply no previous page to return to when we hit Back.

There are a few ways to enable this behavior for {shiny} applications. We can use the shiny.router or blaze packages to ‘simulate’ navigation by modifying the URL paths. There is also functionality within {shiny} it self to update and parse the URL query string, so one can devise with server logic to mimic page-navigation as shown here. These methods work fine, and I have used some of them before for production applications, but none of them implement true multi-page applications.

The {brochure} package, still “work in progress, use at your own risk”, is the only approach so far that enables true multi-page and multi-session {shiny} applications. Meaning, the URL “myapp/page1” runs in its own R session and shares no data with the URL “myapp/page2”. Therefore, variables input by the user or calculated on page 1 have to be somehow saved so page 2 can access them. The {brochure} package includes examples and functionality to do just this through either caching or browser cookies, with browser local storage and databases as additional possibilities. However, all of it has to be done manually, meaning, you as the developer have to know what variables to store and then retrieve in the server on another page. In a single-page {shiny} this is typically not something we worry about, as inputs from “tab 1” are still available in the session, even though we’ve navigated to “tab 2”.

Back to my large {shiny} project I started with. I decided to use {brochure} for the full multi-page experience, and even though the final product was an impressive application, I ran into a major hurdle with sharing data between pages. I used a local disk cache to store variables in one page and then retrieve them later on other pages. This method is very simple and effective, but I had to cache dozens of variables per page, often not single values, but lists, data frames, and even some R6 objects. Commonly, a single variable might be updated in multiple places in the server or in different submodules, and in all these cases I had to remember to cache the object. This resulted in repetitive and complicated code, which I did not anticipate soon enough, so when things became too thick and the project was in a very advanced state, I had no choice but to keep going. There was no time and budget to go back and refactor the data sharing aspect of the code, to come up with a more streamlined solution.

So, now, after a few months, I came back to this problem and I think I have a minor improvement. In the next few examples I outline step-by-step how one can simplify, or at least reduce repetitiveness of page-level caching in {brochure} applications.

In all code below I use these options and load the following packages. The options set the browser and instruct {shiny} to run the application externally, as browser button navigation does not make sense in a viewer pane. Moreover, before each example, we clear the local disk cache and re-initialize it. This ensures that the new examples are not pulling values stored in a previous’ examples cache.

Prep code

options(browser = "/usr/bin/firefox")
options(launch.browser = TRUE)

library(shiny)
library(brochure)
library(cachem)

unlink("brochure_cache", recursive = TRUE)
brochure_cache <- cachem::cache_disk("brochure_cache")

Variables are not shared among {brochure} pages

One of the features of {brochure} is that each page runs in its own session and is ignorant of variables, reactives, inputs, etc. from other pages. We can show this easily with this basic example. The variable some_var is created in the home page (/) and when we try to renderPrint it in page2 we get an error that the object is not found.

brochureApp(
  # First page
  page(
    href = "/",
    ui = fluidPage(
      h1("This is my first page")
    ),
    server = function(input, output, session) {
      some_var <- 10
    }
  ),
  # Second page
  page(
    href = "/page2",
    ui = fluidPage(
      h1("This is my second page"),
      verbatimTextOutput("some_var")
    ),
    server = function(input, output, session) {
      output$some_var <- renderPrint(some_var)
    }
  )
)

Sharing variables with caching to disk

As we said, we have to store some_var somewhere outside the session of the page where it was created so we can retrieve it in the server of another page. The chunk below does that, in / we set the cache value some_var and in page2 we get it, and can print it in the UI.

brochureApp(
  # First page
  page(
    href = "/",
    ui = fluidPage(
      h1("This is my first page")
    ),
    server = function(input, output, session) {
      some_var <- 10
      brochure_cache$set("some_var", some_var)
    }
  ),
  # Second page
  page(
    href = "/page2",
    ui = fluidPage(
      h1("This is my second page"),
      verbatimTextOutput("some_var")
    ),
    server = function(input, output, session) {
      some_var <- brochure_cache$get("some_var")
      output$some_var <- renderPrint(some_var)
    }
  )
)

Now, this is all great, except if we have dozens of variables that we need to share between pages. In this case we’d have to individually cache each of them or update a list and then re-cache the list every time its updated. Or if we need to cache an input, we’d need an observer to monitor that input and cache it on each change, potentially adding unnecessary workload to the session. It becomes cumbersome quickly, and the code gets long and potentially complex, for a relatively basic operation.

brochureApp(
  # First page
  page(
    href = "/",
    ui = fluidPage(
      h1("This is my first page")
    ),
    server = function(input, output, session) {
      var1 <- 10
      brochure_cache$set("var1", var1)
      var2 <- 11
      brochure_cache$set("var2", var2)
      var3 <- 12
      brochure_cache$set("var3", var3)
      var4 <- 13
      brochure_cache$set("var4", var4)
      var5 <- 14
      brochure_cache$set("var5", var5)
      var6 <- 15
      brochure_cache$set("var6", var6)
    }
  ),
  # Second page
  page(
    href = "/page2",
    ui = fluidPage(
      h1("This is my second page"),
      verbatimTextOutput("some_var")
    ),
    server = function(input, output, session) {
      vars <- sapply(1:6, \(i) brochure_cache$get(paste0("var", i)))
      output$some_var <- renderPrint(sum(vars))
    }
  )
)

Simplifying the caching of several variables

What I was hoping for at the time is for a single-step caching of the state of the page session right before the user navigates to another page. It did not occur to me that a mechanism for this was already available in {shiny}. Simply use onSessionEnded to harvest and cache the variables you need to share with another page. So simple.

brochureApp(
  # First page
  page(
    href = "/",
    ui = fluidPage(
      h1("This is my first page")
    ),
    server = function(input, output, session) {
      var1 <- 1
      var2 <- 2
      var3 <- 3
      var4 <- 4
      var5 <- 5
      var6 <- 6

      onSessionEnded(function(x) {
        lapply(1:6, \(i) {
          brochure_cache$set(paste0("var", i), get(paste0("var", i)))
        })
      })
    }
  ),
  # Second page
  page(
    href = "/page2",
    ui = fluidPage(
      h1("This is my second page"),
      verbatimTextOutput("some_var")
    ),
    server = function(input, output, session) {
      vars <- sapply(1:6, \(i) brochure_cache$get(paste0("var", i)))
      output$some_var <- renderPrint(sum(vars))
    }
  )
)

Regardless of how many times var1 is updated during the session, we don’t have to worry about caching it. It will get done when the session is closed, i.e. when the user navigates to another page or closes the web page.

Cleaner approach. A list to store variables to be cached

Thinking about this some more, it certainly makes sense to have a page-level list of variables that need to be cached. Then, when we cache on session end, we can cycle over the contents of that list, not dozens of individual variables. Along the lines of:

brochureApp(
  # First page
  page(
    href = "/",
    ui = fluidPage(
      h1("This is my first page")
    ),
    server = function(input, output, session) {
      var1 <- 1
      var2 <- 2
      var3 <- 3
      var4 <- 4
      var5 <- 5
      var6 <- 6

      to_cache_list <- list()
      to_cache_list$var1 <- var1
      to_cache_list$var3 <- var3
      to_cache_list$var5 <- var5

      onSessionEnded(function() {
        lapply(names(to_cache_list), \(x) {
          brochure_cache$set(x, to_cache_list[[x]])
        })
      })
    }
  ),
  # Second page
  page(
    href = "/page2",
    ui = fluidPage(
      h1("This is my second page"),
      verbatimTextOutput("some_var")
    ),
    server = function(input, output, session) {
      var1 <- brochure_cache$get("var1")
      var3 <- brochure_cache$get("var3")
      var5 <- brochure_cache$get("var5")
      output$some_var <- renderPrint(sum(var1, var3, var5))
    }
  )
)

Still to cumbersome? Cache the kitchen sink

Finally, we can take this a step further, perhaps unwisely, and store the entire environment. Within onSessionEnded, we define a callback function whose environment is a child of the server’s environment. So we can collect the contents of the parent environment, convert to a list for easier handling, and simply cache all of it as pageX_data. Then, when we need to retrieve a value from pageX, we load the cache and index it with the benefit of knowing that the cache’s structure is going to reflect the structure of the pageX environment.

brochureApp(
  # First page
  page(
    href = "/",
    ui = fluidPage(
      h1("This is my first page"),
      sliderInput("slide", "Shared input", 1, 10, 1)
    ),
    server = function(input, output, session) {
      observe({
        slide_value <- brochure_cache$get("page_1_data")[["input"]][["slide"]]
        if (!is.null(slide_value)) {
          updateSliderInput(
            inputId = "slide",
            label = "Shared input",
            value = slide_value
          )
        }
      })
      var1 <- 6
      var2 <- 5
      var3 <- 4
      var4 <- 3
      var5 <- 2
      var6 <- 1

      onSessionEnded(function() {
        env <- environment()
        page_env <- as.list(parent.env(env))
        page_env$output <- NULL
        page_env$session <- NULL
        brochure_cache$set("page_1_data", page_env)
      })
    }
  ),
  # Second page
  page(
    href = "/page2",
    ui = fluidPage(
      h1("This is my second page"),
      h5("Sum of some variables"),
      verbatimTextOutput("some_var"),
      h5("Input from previous page"),
      verbatimTextOutput("slide_input")
    ),
    server = function(input, output, session) {
      var1 <- brochure_cache$get("page_1_data")[["var1"]]
      var3 <- brochure_cache$get("page_1_data")[["var3"]]
      var5 <- brochure_cache$get("page_1_data")[["var5"]]
      output$some_var <- renderPrint(sum(var1, var3, var5))
      slide <- reactive(brochure_cache$get("page_1_data")[["input"]][["slide"]])
      output$slide_input <- renderPrint(slide())
    }
  )
)

The example above also covers caching the input R6 from the {shiny} session and removes the output and session object, although if needed those could be cached as well. Apart from some input value being needed in another page, caching the input makes sense so we can update the input widget when we return to pageX. Again, as each page is its own session, unlike base {shiny}, when we return to pageX the input will be re-initialized with the value set in the code, not with the value previously set by the user, so updating it is necessary.

This last approach comes with a caveat. Its almost never a good idea to cache everything. The input object, for example, is a large R6 object. There could be data frames, lists, model results, etc within the session that have a large footprint and are not used in other pages. This could inflate the size of the cache or cause slowdowns when navigating between pages if saving takes a bit.

Personally, I prefer the approach of a page-level list of objects to be cached. It is more explicit and can be curated to minimize the size of the cache and potentially save time.

Summary

In this post, we explored various methods for sharing data between pages in a multi-page {brochure} {shiny} application. We started with a basic example demonstrating the isolation of sessions in {brochure} and the need for caching to share data. We then showed how to use cachem to store and retrieve variables between pages. To simplify the process, we introduced the use of onSessionEnded to cache variables at the end of a session. Finally, we discussed caching the entire environment and the potential pitfalls of this approach. These methods can help streamline data sharing in multi-page {brochure} applications, reducing repetitive code and improving maintainability.