Advanced Reactivity and Dependencies

Author

Mark Andrews

Abstract

This guide addresses the reactive patterns that arise in more realistic applications: inputs whose available choices depend on other inputs, multiple outputs sharing a single reactive computation, and applications complex enough to require systematic debugging. We examine the reactive execution model in some depth and introduce observer patterns and dynamic UI updates.

Observer patterns and dependent inputs

Sometimes an input’s valid range or available choices should change depending on what the user has selected elsewhere. For example, if the user selects a dataset, the variable selector should update to show only the columns of that dataset. This requires an observer that watches one input and calls an update* function to modify another.

observeEvent(input$x, {...}) runs its code block whenever input$x changes. Inside that block you can call updateSelectInput, updateSliderInput, updateRadioButtons, and their siblings to modify other controls.

datasets <- list(
  mtcars  = mtcars,
  iris    = iris,
  airquality = airquality
)

ui <- fluidPage(
  sidebarLayout(
    sidebarPanel(
      selectInput("dataset", "Dataset", choices = names(datasets)),
      selectInput("xvar", "X variable", choices = NULL),
      selectInput("yvar", "Y variable", choices = NULL)
    ),
    mainPanel(plotOutput("scatter"))
  )
)

server <- function(input, output, session) {
  observeEvent(input$dataset, {
    cols <- names(datasets[[input$dataset]])
    numeric_cols <- cols[sapply(datasets[[input$dataset]], is.numeric)]
    updateSelectInput(session, "xvar", choices = numeric_cols,
                      selected = numeric_cols[1])
    updateSelectInput(session, "yvar", choices = numeric_cols,
                      selected = numeric_cols[min(2, length(numeric_cols))])
  })

  output$scatter <- renderPlot({
    req(input$xvar, input$yvar)
    req(input$xvar %in% names(datasets[[input$dataset]]),
        input$yvar  %in% names(datasets[[input$dataset]]))
    df <- datasets[[input$dataset]]
    ggplot(df, aes(x = .data[[input$xvar]], y = .data[[input$yvar]])) +
      geom_point(colour = "steelblue", alpha = 0.7) +
      labs(x = input$xvar, y = input$yvar) +
      theme_minimal()
  })
}

shinyApp(ui, server)

The session argument must be added to the server function signature when using update* functions.

The two req() calls in renderPlot serve different purposes and both are necessary. The first, req(input$xvar, input$yvar), guards against the NULL case: when the application first loads, xvar and yvar start empty before the observeEvent has a chance to populate them.

The second req() guards against a subtler timing problem. When the user changes the dataset, two things happen in quick succession: input$dataset updates immediately, and observeEvent fires and calls updateSelectInput to repopulate xvar and yvar with the new dataset’s columns. But renderPlot is also watching input$dataset (indirectly, because the render block reads it), so it can re-execute between the moment input$dataset changes and the moment the new xvar/yvar values propagate. At that instant, input$xvar still holds a column name from the old dataset. For example, it might still be "mpg" after the dataset has switched to iris. The ggplot call then throws an error because iris has no mpg column.

The fix is to check that the current xvar and yvar values actually exist in the current dataset before proceeding. If they do not, req() silences the execution silently and waits for the next reactive flush, by which point the updated column names will have arrived.

Dynamic UI with renderUI

When the number or type of controls needs to change substantially based on user input, renderUI and uiOutput allow the entire panel to be rebuilt reactively.

uiOutput and renderUI work on the same principle as plotOutput and renderPlot, but instead of producing a plot the pair produces arbitrary UI controls. uiOutput("params") places a placeholder in the page — a <div> that Shiny will fill in later. On the server side, renderUI returns one or more Shiny tag objects, which Shiny serialises as HTML and injects into that placeholder whenever the block re-executes. Because the block is a reactive context, this happens automatically whenever any input it reads changes — here, whenever input$type changes, the parameter sliders are replaced with the appropriate pair for that distribution.

ui <- fluidPage(
  sidebarLayout(
    sidebarPanel(
      radioButtons("type", "Simulation type",
                   choices = c("Normal", "Beta", "Binomial")),
      uiOutput("params")
    ),
    mainPanel(plotOutput("simplot"))
  )
)

server <- function(input, output) {
  output$params <- renderUI({
    switch(input$type,
      Normal   = tagList(
        sliderInput("p1", "Mean",  min = -5, max = 5, value = 0),
        sliderInput("p2", "SD",    min = 0.1, max = 5, value = 1)
      ),
      Beta     = tagList(
        sliderInput("p1", "Shape 1", min = 0.1, max = 10, value = 2),
        sliderInput("p2", "Shape 2", min = 0.1, max = 10, value = 5)
      ),
      Binomial = tagList(
        sliderInput("p1", "n trials", min = 1, max = 100, value = 20),
        sliderInput("p2", "p success", min = 0, max = 1, value = 0.5, step = 0.05)
      )
    )
  })

  output$simplot <- renderPlot({
    req(input$p1, input$p2)
    x <- switch(input$type,
                Normal   = rnorm(1000, input$p1, input$p2),
                Beta     = rbeta(1000, input$p1, input$p2),
                Binomial = rbinom(1000, size = as.integer(input$p1), prob = input$p2))
    df <- data.frame(x = x)
    ggplot(df, aes(x = x)) +
      geom_histogram(aes(y = after_stat(density)),
                     bins = 40, fill = "steelblue", colour = "white") +
      labs(x = input$type) +
      theme_minimal()
  })
}

shinyApp(ui, server)

Each branch of the switch returns a tagList(). tagList is a container for multiple Shiny tag objects — it groups them so they can be returned as a single value, which is what renderUI requires. It adds no wrapping HTML element of its own: the controls simply appear consecutively in the page. You could use any other container such as div() or wellPanel() instead, but those impose extra structure on the page; tagList is the minimal option when you only need to bundle controls for transport, not to arrange them. If a branch needed to return only a single control, tagList would be unnecessary and the control could be returned directly.

Understanding the reactive graph

Shiny’s reactive graph determines execution order. When input$n changes, Shiny invalidates every reactive expression and render block that directly or indirectly reads input$n. On the next flush, each invalidated consumer re-executes in dependency order.

A few rules that follow from this:

  • You cannot read input$x outside a reactive context (a render*, reactive, or observe/observeEvent block). Attempting to do so produces an error about “operation not allowed without an active reactive context.”
  • A reactive expression (reactive({...})) is lazy: it only re-executes when a consumer actually calls it and its inputs have changed.
  • observe({...}) is eager: it re-executes whenever its inputs change, whether or not anything has called it. Use it for side effects (printing, writing files, calling update* functions).

Debugging reactive applications

The most common debugging approaches in order of intrusiveness are print statements, req(), browser(), and reactlog.

Print statements confirm that values are what you expect and that reactive blocks are running when you think they should. Output appears in the R console, not in the browser.

ui <- fluidPage(
  sliderInput("n", "Sample size", min = 10, max = 500, value = 100),
  plotOutput("hist")
)

server <- function(input, output) {
  data <- reactive({
    cat("Recomputing data; n =", input$n, "\n")
    rnorm(input$n)
  })

  output$hist <- renderPlot({
    d <- data()
    cat("Rendering plot; length =", length(d), "\n")
    hist(d, col = "steelblue", border = "white")
  })
}

shinyApp(ui, server)

Move the slider and watch the console. You will see that data() recomputes exactly once per slider change, and that the plot render fires immediately after.

req() for missing or invalid values. req(input$x) silently halts the current reactive block if input$x is NULL, NA, "", or FALSE. It is the standard fix for outputs that throw errors or appear blank on startup.

ui <- fluidPage(
  textInput("title", "Plot title"),  # starts empty
  plotOutput("hist")
)

server <- function(input, output) {
  output$hist <- renderPlot({
    req(input$title)  # wait until the user has typed something
    hist(rnorm(200), main = input$title,
         col = "steelblue", border = "white")
  })
}

shinyApp(ui, server)

Without the req(), the plot would render immediately with an empty string as the title. With it, the plot waits until the user provides non-empty input. req() also accepts multiple arguments and a logical expression: req(nchar(input$title) >= 3) would wait until the title is at least three characters.

browser() inside reactive blocks. Placing browser() inside a renderPlot or reactive block suspends execution and drops you into the R debugger the next time that block runs. You can then inspect all local variables and inputs interactively.

server <- function(input, output) {
  output$hist <- renderPlot({
    browser()          # execution pauses here; inspect input$n in the console
    x <- rnorm(input$n)
    hist(x, col = "steelblue", border = "white")
  })
}

Type n at the debugger prompt to step to the next line, c to continue, and Q to quit the debugger. Remove browser() before sharing the application with anyone else.

reactlog. The reactlog package provides a visual diagram of the reactive graph and shows which nodes recomputed and in what order during a session. It is the most powerful tool for understanding why an output is or is not updating.

options(shiny.reactlog = TRUE)

ui <- fluidPage(
  sliderInput("n",    "Sample size", min = 10, max = 500, value = 100),
  sliderInput("bins", "Bins",        min = 5,  max = 50,  value = 20),
  plotOutput("hist")
)

server <- function(input, output) {
  data <- reactive(rnorm(input$n))

  output$hist <- renderPlot({
    df <- data.frame(x = data())
    ggplot(df, aes(x = x)) +
      geom_histogram(bins = input$bins, fill = "steelblue", colour = "white") +
      theme_minimal()
  })
}

shinyApp(ui, server)
# After interacting with the app, call:
# shiny::reactlogShow()

The diagram shows data as a node between input$n and output$hist, and input$bins as a direct dependency of output$hist. Moving the bins slider recomputes only the plot; moving the n slider recomputes data() first and then the plot. This makes the structure of the reactive graph visible at a glance.