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
# 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])
})
# Track the current selection via a reactiveVal, which can also be updated by
# other modules, etc.
selected_group <- reactiveVal(NULL)
# Keep the reactiveVal in sync with the input.
observe({
selected_group(input$group)
})
# Update the input when the reactiveVal or the valid values change.
observe({
groups <- valid_groups()
current_group <- selected_group()
selected <- if (isTRUE(current_group %in% groups)) current_group
updateSelectInput(
session,
"group",
choices = groups,
selected = selected
)
})
# 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)
}
# 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: validated_reactive_val()
The shinybatch
package provides validated_reactive_val()
to solve this exact problem. It works like a standard shiny::reactiveVal()
but with an added validation expression. When you try to set its value, it only accepts the new value if it passes the validation. Otherwise, it reverts to NULL
.
This ensures that the reactive value can never hold an invalid state. Downstream calculations that use shiny::req()
on this value will be correctly paused during the brief transient period, preventing the error.
In the updated app below, we’ve replaced shiny::reactiveVal()
with shinybatch::validated_reactive_val()
. Notice how the core logic changes very little. However, if you try to invalidate the “Group” by changing the “Level”, the error modal no longer appears. The application remains responsive and correctly waits until a valid state is available.
#| '!! 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
webr::install("shinybatch", repos = "https://shinyworks.r-universe.dev/")
# shiny App to demonstrate the effect of validated_reactive_val()
library(shiny)
library(shinybatch)
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 validated_reactive_val()"),
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: validated_reactive_val() ensures that its value
# is always valid. The validation_expr uses the .vrv() pronoun to refer
# to selected_group's current value (before any update).
selected_group <- shinybatch::validated_reactive_val({
groups <- valid_groups()
current_group <- .vrv()
if (isTRUE(current_group %in% groups)) current_group
})
# Keep the validated_reactive_val in sync with the input.
observe({
selected_group(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 should now be unreachable, as req(selected_group()) will
# prevent execution when the inputs are inconsistent.
showModal(modalDialog(
title = "Error",
p("This modal should not appear!"),
easyClose = FALSE,
footer = NULL
))
# Simulate a long-running process
Sys.sleep(5)
removeModal()
})
}
shinyApp(ui, server)