ETC5523: Communicating with Data

Workshop 11: Trace, package and audit a Shiny app

Author

Michael Lydeamore

Published

1 November 2001

🎯 Objectives

By the end of this workshop, you will be able to:

  • use reactlog to inspect a Shiny dependency graph;
  • replace repeated calculations with one shared reactive expression;
  • add a package function that launches a Shiny app; and
  • audit an app before web deployment.

1. Clone and run the app (5 minutes)

Clone the workshop repository and open wplshare.Rproj:

git clone https://github.com/numbats/etc5523-wpl-share.git
cd etc5523-wpl-share

Install any packages you do not already have:

install.packages(c(
  "bslib", "devtools", "dplyr", "ggplot2", "reactlog", "rsconnect", "shiny"
))

Start a fresh R session, enable reactive logging, and run the app:

reactlog::reactlog_enable()
shiny::runApp("inst/app")

Change both inputs and check the number, plot and table.

2. Inspect the current graph (10 minutes)

With the app running, open the reactive log:

  • macOS: Cmd + F3
  • Windows/Linux: Ctrl + F3

Find input$season, input$minimum_runs and the four outputs. The log may also contain browser and layout nodes; use search or double-click an output to isolate its dependency family.

Then return to the app and set Minimum runs to exactly 150 in season 2024/25. Compare the number, plot and table before inspecting app.R.

Answer:

  1. Why does the table disagree with the other outputs?
  2. Why do both inputs connect directly to every output in reactlog?
Note

If the keyboard shortcut does not work, stop the app and run shiny::reactlogShow() in the console.

What the first run reveals

The number and plot use runs >= input$minimum_runs, while the table uses runs > input$minimum_runs. JL Jonassen has exactly 150 runs in 2024/25, so the table omits a batter counted and plotted elsewhere.

Each output contains its own filter and reads both inputs directly. There is no shared reactive node between the inputs and outputs.

3. Put the filter in one place (15 minutes)

Add this labelled reactive expression at the top of server():

selected_batters <- reactive({
  batters |>
    filter(
      season == input$season,
      runs >= input$minimum_runs
    )
}, label = "selected_batters")

Replace the repeated filtering code in all four outputs with calls to selected_batters().

Run the app and repeat the 150-run test. Then stop the app, clear the old log, run it again, and change one input:

shiny::reactlogReset()
shiny::runApp("inst/app")

Reopen reactlog. Identify which nodes depend on selected_batters and step through what happens after the input changes.

Refactored server

server <- function(input, output, session) {
  selected_batters <- reactive({
    batters |>
      filter(
        season == input$season,
        runs >= input$minimum_runs
      )
  }, label = "selected_batters")

  output$number_shown <- renderText({
    nrow(selected_batters())
  })

  output$leading_batter <- renderText({
    selected <- selected_batters()
    req(nrow(selected) > 0)
    selected$striker[which.max(selected$runs)]
  })

  output$batters_plot <- renderPlot({
    selected_batters() |>
      ggplot(aes(
        x = dot_percent,
        y = boundary_percent,
        size = balls_faced
      )) +
      geom_point(colour = "#006DAE", alpha = 0.7) +
      labs(
        x = "Dot-ball percentage",
        y = "Boundary percentage",
        size = "Balls faced"
      ) +
      theme_minimal(base_size = 14)
  })

  output$batters_table <- renderTable({
    selected_batters() |>
      arrange(desc(runs)) |>
      select(striker, batting_team, runs, balls_faced) |>
      head(5)
  })
}

Both inputs now feed selected_batters; all four outputs depend on that shared node. When an input changes, the shared reactive and its downstream outputs are invalidated.

4. Make the app launchable from the package (12 minutes)

The repository already has the structure of an R package. Files under inst/app will be installed with it.

Create R/run_app.R, then add a documented, exported run_app() function:

usethis::use_r("run_app")

The function should:

  1. locate the installed app directory with system.file(); and
  2. pass that directory to shiny::runApp().

Generate the documentation, install the package and test the launcher:

devtools::document()
devtools::install()
wplshare::run_app()

Why would a path such as /Users/name/wpl-share/inst/app fail for another package user?

Package launcher

#' Run the WPL batting explorer
#'
#' @return No return value; called for its side effect of launching the app.
#' @export
run_app <- function() {
  app_dir <- system.file("app", package = "wplshare")
  shiny::runApp(app_dir)
}

inst/app becomes app at the top level of the installed package. system.file() finds that location in the active R library rather than assuming where the source repository exists on a particular computer.

5. Audit the web deployment (10 minutes)

Treat the hosting service as a clean Linux computer that receives only the app bundle. Mark each item ready, not ready, or not applicable:

  • all required files are inside inst/app;
  • file paths are relative and match the exact case of the file names;
  • package dependencies can be detected from the app code;
  • there is no install.packages() call in the app;
  • no password, token or secret is stored in the repository;
  • every possible input state produces a useful response; and
  • any durable writes use storage outside the app’s local filesystem.

Ask rsconnect what it detects:

rsconnect::appDependencies("inst/app")

Which audit item does the current app handle least well? If time permits, make one improvement and rerun the app.

Audit findings

The data are bundled under inst/app/data, paths are relative, packages are loaded explicitly, and the source contains no installation calls or secrets.

The empty selection is poorly communicated. req() prevents an error in the leader box but leaves it blank. One possible replacement is:

output$leading_batter <- renderText({
  selected <- selected_batters()

  if (nrow(selected) == 0) {
    return("No batter meets this threshold")
  }

  selected$striker[which.max(selected$runs)]
})

A deployed app should use a database, object store or other persistent service for durable writes because hosted instances can restart or be replaced.