options(browser = "/usr/bin/firefox")
options(launch.browser = TRUE)
library(shiny)
library(brochure)
library(cachem)
unlink("brochure_cache", recursive = TRUE)
<- cachem::cache_disk("brochure_cache") brochure_cache
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
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) {
<- 1
var1 <- 2
var2 <- 3
var3 <- 4
var4 <- 5
var5 <- 6
var6
onSessionEnded(function(x) {
lapply(1:6, \(i) {
$set(paste0("var", i), get(paste0("var", i)))
brochure_cache
})
})
}
),# Second page
page(
href = "/page2",
ui = fluidPage(
h1("This is my second page"),
verbatimTextOutput("some_var")
),server = function(input, output, session) {
<- sapply(1:6, \(i) brochure_cache$get(paste0("var", i)))
vars $some_var <- renderPrint(sum(vars))
output
}
) )
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) {
<- 1
var1 <- 2
var2 <- 3
var3 <- 4
var4 <- 5
var5 <- 6
var6
<- list()
to_cache_list $var1 <- var1
to_cache_list$var3 <- var3
to_cache_list$var5 <- var5
to_cache_list
onSessionEnded(function() {
lapply(names(to_cache_list), \(x) {
$set(x, to_cache_list[[x]])
brochure_cache
})
})
}
),# Second page
page(
href = "/page2",
ui = fluidPage(
h1("This is my second page"),
verbatimTextOutput("some_var")
),server = function(input, output, session) {
<- brochure_cache$get("var1")
var1 <- brochure_cache$get("var3")
var3 <- brochure_cache$get("var5")
var5 $some_var <- renderPrint(sum(var1, var3, var5))
output
}
) )
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({
<- brochure_cache$get("page_1_data")[["input"]][["slide"]]
slide_value if (!is.null(slide_value)) {
updateSliderInput(
inputId = "slide",
label = "Shared input",
value = slide_value
)
}
})<- 6
var1 <- 5
var2 <- 4
var3 <- 3
var4 <- 2
var5 <- 1
var6
onSessionEnded(function() {
<- environment()
env <- as.list(parent.env(env))
page_env $output <- NULL
page_env$session <- NULL
page_env$set("page_1_data", page_env)
brochure_cache
})
}
),# 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) {
<- brochure_cache$get("page_1_data")[["var1"]]
var1 <- brochure_cache$get("page_1_data")[["var3"]]
var3 <- brochure_cache$get("page_1_data")[["var5"]]
var5 $some_var <- renderPrint(sum(var1, var3, var5))
output<- reactive(brochure_cache$get("page_1_data")[["input"]][["slide"]])
slide $slide_input <- renderPrint(slide())
output
}
) )
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.