Skip to contents

In Shiny applications, it’s common to have inputs that depend on one another. For example, a user might first select a state, and a second dropdown is then populated with cities from that state. This is often called “cascading inputs”.

While this is a powerful feature, it can introduce subtle bugs related to timing. When the first input changes, there is a brief moment where the second input still holds its old value, which may now be invalid. If a downstream reactive calculation depends on both inputs, it can briefly receive an inconsistent state, potentially leading to errors or triggering slow, unnecessary computations.

The Problem

Consider the following application. It has two selectInput controls: “Level” and “Group”. The available choices for “Group” depend on the selected “Level”. When you change the “Level”, the “Group” input becomes temporarily invalid before it is updated with new choices.

In this example, we’ve added a Sys.sleep(5) to simulate a long-running operation that gets triggered when the app receives an inconsistent state. Try changing the “Level” from “A” to “B”. You will see a modal dialog appear for 5 seconds, demonstrating the problem.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| label: motivating-example
#| standalone: true
#| viewerHeight: 300
#| components: [editor, viewer]
#| layout: vertical
# shiny App to demonstrate the need for validated_reactive_val()

library(shiny)


all_data <- data.frame(
  level = c("A", "A", "A", "A", "B", "B", "B", "B"),
  group = c("A1", "A1", "A2", "A2", "B1", "B1", "B2", "B2"),
  member = c("A1a", "A1b", "A2a", "A2b", "B1a", "B1b", "B2a", "B2b")
)

ui <- fluidPage(
  titlePanel("Motivating Example"),
  sidebarLayout(
    sidebarPanel(
      selectInput(
        "level",
        "Level (controls Group)",
        choices = unique(all_data$level)
      ),
      selectInput(
        "group",
        "Group",
        choices = NULL
      )
    ),
    mainPanel(
      h3("Members"),
      verbatimTextOutput("members_out")
    )
  )
)

server <- function(input, output, session) {
  # Keep the list of possible group values synced with the level.
  valid_groups <- reactive({
    unique(all_data$group[all_data$level == input$level])
  })

  # When the level changes, the selected group may no longer be valid.
  selected_group <- reactive({
    if (isTRUE(input$group %in% valid_groups())) {
      input$group
    } # Returns NULL if invalid
  })

  # Update the input when the reactiveVal or the valid values change.
  observe({
    updateSelectInput(
      session,
      "group",
      choices = valid_groups(),
      selected = selected_group()
    )
  })

  # Display the members of the selected group. We pause the operation when it
  # encounters an invalid state to emphasize the potential issue.
  output$members_out <- renderPrint({
    req(input$level, input$group)

    filtered_data <- all_data[
      all_data$level == input$level & all_data$group == input$group,
    ]
    if (nrow(filtered_data)) {
      return(filtered_data)
    }

    # Make the problem obvious with a modal + timer
    showModal(modalDialog(
      title = "Error",
      p("Triggered a slow operation with bad data!"),
      p("This dialog will auto-close after 5 seconds."),
      easyClose = FALSE,
      footer = NULL
    ))
    # Simulate a long-running process
    Sys.sleep(5)
    removeModal()
  })
}

shinyApp(ui, server)

The Solution: vrv_fct()

The chains package provides vrv_fct() (and a single-value version, vrv_fct_scalar()) to solve this problem. vrv_fct() is a specialized wrapper around the more general validated_reactive_val() to ensure that the value is always factor-like, validating to a specific set of allowed values. You provide it an (optionally reactive) expression for the valid levels, and vrv_fct() guarantees that its values are always in those levels. If its current set of values becomes invalid (either because the levels change or you attempt to set an invalid value), it returns a default value (which can also be calculated via a reactive expression) instead.

By guaranteeing a valid state, vrv_fct() prevents downstream reactives from ever observing an inconsistent set of inputs. When the vrv_fct()’s value is invalid, it returns its default (NULL if not otherwise specified). A downstream reactive using shiny::req() will then correctly halt execution, preventing errors or unnecessary calculations until a consistent state is restored.

In the updated app below, we use chains::vrv_fct_scalar() to manage the state of the “Group” input. Notice how this simplifies the server function significantly. When you invalidate the “Group” by changing the “Level”, the downstream calculation never sees the inconsistent state, so the error modal no longer appears. The application remains responsive and proceeds with the consistent inputs.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| label: solution-example
#| standalone: true
#| viewerHeight: 300
#| components: [editor, viewer]
#| layout: vertical
webr::install("chains", repos = c("https://chains.shinyworks.org/", "https://shinyworks.r-universe.dev/", "https://stbl.wranglezone.org", "https://wranglezone.r-universe.dev", "https://repo.r-wasm.org/"))
# shiny App to demonstrate vrv_fct_scalar()

library(shiny)
library(chains)

all_data <- data.frame(
  level = c("A", "A", "A", "A", "B", "B", "B", "B"),
  group = c("A1", "A1", "A2", "A2", "B1", "B1", "B2", "B2"),
  member = c("A1a", "A1b", "A2a", "A2b", "B1a", "B1b", "B2a", "B2b")
)

ui <- fluidPage(
  titlePanel("With vrv_fct_scalar()"),
  sidebarLayout(
    sidebarPanel(
      selectInput(
        "level",
        "Level (controls Group)",
        choices = unique(all_data$level)
      ),
      selectInput(
        "group",
        "Group",
        choices = NULL
      )
    ),
    mainPanel(
      h3("Members"),
      verbatimTextOutput("members_out")
    )
  )
)

server <- function(input, output, session) {
  # Keep the list of possible group values synced with the level.
  valid_groups <- reactive({
    unique(all_data$group[all_data$level == input$level])
  })

  # This is the core change: vrv_fct_scalar() ensures that its value is
  # always one of the valid_groups.
  selected_group <- vrv_fct_scalar(
    levels = valid_groups(),
    value = reactive(input$group)
  )

  # Update the input when the validated_reactive_val or the valid values change.
  observe({
    updateSelectInput(
      session,
      "group",
      choices = valid_groups(),
      selected = selected_group()
    )
  })

  # Display the members of the selected group. We pause the operation when it
  # encounters an invalid state to emphasize the potential issue.
  output$members_out <- renderPrint({
    req(input$level, selected_group())

    filtered_data <- all_data[
      all_data$level == input$level & all_data$group == selected_group(),
    ]
    if (nrow(filtered_data)) {
      return(filtered_data)
    }

    # This block is now unreachable, since we returned filtered_data and we will
    # never execute this render with an invalid selected group.
    showModal(modalDialog(
      title = "Error",
      p("This modal will not appear!"),
      easyClose = FALSE,
      footer = NULL
    ))
    # Simulate a long-running process
    Sys.sleep(5)
    removeModal()
  })
}

shinyApp(ui, server)