knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE)
library(shiny) library(brochure)
Everything in brochure follows from one sentence: a page is a Shiny session, and two sessions never meet.
Opening /contact does not hide / and show something else. It closes the
first session and opens a second one, which loads its own UI and runs its own
server. Nothing computed on the first page is there when the second starts.
That is not a limitation to work around. It is the trade: you get urls that mean something, and you give up the shared reactive graph that a single page app leans on. This article is about what that costs and how the usual patterns translate.
| Single page Shiny | brochure |
| --- | --- |
| tabsetPanel() / navbarPage() | one page() per tab |
| conditionalPanel() on a "screen" input | one page() per screen |
| updateTabsetPanel() to move around | a link, or server_redirect() |
| renderUI() picking the screen | the url picks the page |
| a navbar written once in the ui | wrapped, or a tag list in ... |
| reactiveValues() shared by the screens | a cookie plus a store |
| moduleServer() per screen | the same modules, one per page |
Modules survive the move unchanged, which is most of the work saved. What does not survive is whatever passed values between them.
tags$a(href = "/contact", "Contact")
No router to configure, no observer, no updateTabsetPanel(). The browser asks
the server for that url, and the back button, the middle click and "open in a
new tab" all work because nothing was faked.
server_redirect() is for the other case: navigating as the result of
something, once a form is submitted or a login accepted.
page( href = "/login", ui = tagList(passwordInput("pw", "Password"), actionButton("go", "Log in")), server = function(input, output, session) { observeEvent(input$go, { if (identical(input$pw, Sys.getenv("APP_PASSWORD"))) { server_redirect("/") } }) } )
Three things reach every page, and they are the three places to put anything
common — see vignette("brochure") for the mechanics:
brochureApp() in ...: css, favicon, a
<script>wrapped, a function applied to each page's UI: the layout, a navbar, a
footerThat last one deserves care. brochureApp() runs once, when the app starts,
not once per visitor:
store <- cachem::cache_disk() brochureApp( home(), login() )
store is built once and shared by everyone, which is exactly what you want
for a cache, a database pool, a lookup table read from disk. It is exactly what
you do not want for anything belonging to one visitor: an identifier minted
there is handed to every one of them.
There are two places, and the difference between them is who chooses the value.
The url, when the visitor does. A parameterised href makes the state part of the address, which means it can be linked to, bookmarked and shared:
page( href = "/product/:sku", ui = function(request) { mod_product_ui("product") }, server = function(input, output, session) { mod_product_server("product", sku = get_keys()$sku) } )
A cookie plus a store, when it is yours to decide. The cookie names a
session; the data lives server side, keyed by that name. vignette("cookies")
covers it, including the two mistakes that are silent when made.
What does not work, and is worth naming because it is the reflex you are
bringing with you: a reactiveValues() at app scope. It is a single object,
shared by every visitor and every page, and it is not reactive across sessions.
The same goes for assigning into the global environment.
Each one is a full page load: a new http request, a new websocket, the UI built
again, and every renderPlot() on the arriving page computed from scratch.
That is a fraction of a second on a page that reads a small data frame, and
several seconds on one that queries a database on startup.
So the data a page needs is fetched again each time it is opened, and caching it at app scope is what makes that cheap:
# Read once, shared by every session reference <- readRDS(app_sys("data/reference.rds")) # Or memoised, if it has to be fetched get_sales <- memoise::memoise(function(region) { DBI::dbGetQuery(pool, "select * from sales where region = ?", list(region)) })
The upside of the same design: a session loads one page's worth of UI and runs one page's worth of server. A visitor reading the contact page is not holding the dashboard's reactive graph open.
A session is one visitor on one page: two people reading /dashboard are two
sessions, and one person with /dashboard and /admin open in two tabs is two
sessions as well. That is Shiny's usual arrangement, and brochure does not
change it.
What it does not change either is that all of them live in one R process.
Pages are not processes: a page that blocks holds up every other page and every
other visitor. On a two page app where /slow takes five seconds to render,
/fast answers in 0.2s on its own and in 4.8s when it is opened while /slow
is rendering.
So the reasons to reach for {promises} and {future} in a Shiny app are the
same ones here, and splitting an app into pages is not one of them. One thing
to keep in mind when you do: a navigation ends the session, so a future still
running when the visitor leaves has nowhere left to deliver.
An app whose screens genuinely interact — a filter in a sidebar feeding six
outputs, a selection in one tab driving a plot in another — is one page. Making
it several means inventing a way to carry the shared state across, and the
result is a worse version of what tabsetPanel() gave you for free.
Brochure earns its place when the parts of your app are separate: a landing page, a login, a dashboard, an admin panel, a public report at a url you can send to someone. Different urls because they are different things — not because the app happens to have several screens.
vignette("brochure") — pages, routing, and what reaches every pagevignette("cookies") — the cookie and store pattern, in fullvignette("testing") — testing the routing without a browservignette("golem") — the same app, as a packageAny scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.