Introduction to shinyreprex

Using shinyreprex

reprex_reactive takes a reactive object and converts it into a script that can be reused outside of the Shiny application to reproduce the result of the reactive. This can be sent to a simple verbatimTextOutput or something more UX friendly such as the {highlighter} package to display the script in the UI.

A script on its own reproduces the code, but not the environment it ran in. The remaining three functions close that gap:

Function Purpose
reprex_reactive Turn a reactive into a stand-alone script
reprex_packages List the packages needed to run that script
reprex_lockfile Capture those packages, their versions and sources as an renv lockfile
register_reactives Record reactives so the two functions above can be called with no arguments

A worked example covering all four ships with the package:

shiny::runExample("lockfile", package = "shinyreprex")

Reproducing the Environment

reprex_reactive emits the library() calls a script needs, but not the versions those packages were at. reprex_lockfile records them, along with the R version and the full recursive dependency tree, so the environment can be rebuilt rather than approximated.

output$lockfile <- downloadHandler(
  filename = function() "renv.lock",
  content = function(file) reprex_lockfile(summary_tbl, lockfile = file)
)

Whoever receives the lockfile restores it with renv:

renv::restore(lockfile = "renv.lock")

Use reprex_packages when you want to show the detected packages before pinning them, for example to let the user narrow the set:

reprex_lockfile(summary_tbl, packages = input$packages, lockfile = file)

Supplying packages is also the escape hatch when the detector cannot see a package, such as one attached only for an operator or an S3 method.

Registering Reactives Across Modules

In a modular application the reactives worth reproducing live inside their own modules. Rather than returning them all up to the top level, each module registers what it owns:

summaryServer <- function(id) {
  moduleServer(id, function(input, output, session) {
    summary_tbl <- reactive({
      aggregate(Sepal.Width ~ Species, data = iris, FUN = get(input$summary_fn))
    })

    register_reactives(summary_tbl)

    summary_tbl
  })
}

reprex_lockfile() and reprex_packages() then cover the whole application when called with no arguments. Registrations are namespaced by module, held on the session, and discarded when the session ends, so they are never shared between concurrent users.

Best Practices

Bind reprex_reactive call to Event

The code to reproduce a given reactive will be updated whenever an input or reactive feeding into the provided reactive is updated, therefore it is recommended to have the reactive as an event attached.

width_range <- reactive({
  iris_filt <- dplyr::filter(iris, Species == "versicolor")
  range(iris_filt$Petal.Width)
})

# Good
repro_range <- reactive(reprex_reactive(width_range)) |>
  bindEvent(width_range())
  
# Bad
repro_range <- reactive(reprex_reactive(width_range))

Register Reactives at Module Setup

register_reactives does not evaluate the reactive it is given, so it can be called as soon as the reactive is defined, even when that reactive is still gated behind shiny::req or inputs that have yet to be set. Registering at setup means a lockfile covers every module regardless of which parts of the application the user happened to visit.

# Good
summary_tbl <- reactive({
  shiny::req(input$summary_fn)
  aggregate(Sepal.Width ~ Species, data = iris, FUN = get(input$summary_fn))
})

register_reactives(summary_tbl)

# Bad - the reactive is only registered once the user has viewed the output,
# so a lockfile downloaded beforehand silently misses it
output$table <- renderTable({
  register_reactives(summary_tbl)
  summary_tbl()
})

Put Side-Effects in Observers

This is general best-practice when developing Shiny applications, but avoid putting code for its side effects in reactive expressions, and instead create smaller reactive calls, and have observers running the code not intended for reproducing outputs.

# Good
width_range <- reactive({
  iris_filt <- dplyr::filter(iris, Species == "versicolor")
  range(iris_filt$Petal.Width)
})

observe({
  updateSliderInput("width", width_range())
})

# Bad
width_range <- reactive({
  iris_filt <- dplyr::filter(iris, Species == "versicolor")
  widths <- range(iris_filt$Petal.Width)
  updateSliderInput("width", widths)
  widths
})

summary_data <- reactive({
  iris_filt <- dplyr::filter(iris, Species == "versicolor")
  shinyjs::toggle("table", condition = nrow(iris_filt) > 0L)
  dplyr::summarise(iris_filt, dplyr::across(where(is.numeric), mean))
})

Create a Business Logic Package

In order that developers can easily recreate outputs generated in Shiny applications, add any business logic, such as ETL, data manipulation and modelling, to a separate package. This will allow users to recreate the tables and plots generated in the app without having to install all the packages associated with the application.

Use Secrets in Reactives

If you are using secrets, such as environment variables, make sure that they are defined within a reactive expression. If it is defined in the module, or in the global environment, the secret will be written in the assignment.

# Good
moduleServer(id, function(input, output, session) {
  my_reactive <- reactive({
    api_key <- Sys.getenv("MY_API_KEY")
    ...
  })
})

# Bad
moduleServer(id, function(input, output, session) {
  api_key <- Sys.getenv("MY_API_KEY")

  my_reactive <- reactive({
    ...
  })
})

Limitations

Reproducible code is generated by reading the expression held in a reactive, rather than by tracing what it does when it runs. That keeps the process cheap and independent of the current inputs, but it sets some boundaries worth knowing about.

Only the Reactive Expression is Read

Calls made inside a function that is defined elsewhere are not visited, so anything used only in that function is neither reproduced nor detected as a package.

summarise_widths <- function(dat) dplyr::summarise(dat, mean(Petal.Width))

my_reactive <- reactive(summarise_widths(iris))

Here the script reproduces the call to summarise_widths, but dplyr is not reported. Keeping the work inside the reactive, or moving it into a package that the recipient installs, avoids this. See also Create a Business Logic Package above.

Non-Standard Evaluation is Best-Effort

Data-masking functions such as dplyr::filter or subset refer to columns as if they were variables. Since the column names are only distinguishable from real variables at run time, a collision between the two can produce a misleading line in the script:

moduleServer(id, function(input, output, session) {
  Species <- "versicolor"

  my_reactive <- reactive(subset(iris, Species == "setosa"))
})

Species in the reactive is a column of iris, but a variable of the same name exists in the module, so the script includes an unnecessary Species <- "versicolor" assignment. The reproduced code still runs, because subset masks the variable with the column, but the extra line is misleading. Avoiding names that clash with the columns being masked avoids this.

Similarly, an input captured with rlang::quo and spliced back in later cannot be resolved to its value, and appears in the script with the !! still in place.

Detected Packages Can Be a Superset

reprex_packages walks every branch of an if or switch rather than evaluating the condition and following only the branch that would be taken. A lockfile may therefore pin a package that the script does not end up needing. This is deliberate: a lockfile holding a package that is not needed is safer than one missing a package that is.