Reactive programming
Lesson 2 — R Shiny
Learning objectives
- Describe the reactive graph and how invalidation propagates
- Choose correctly between
reactive(),observe()andeventReactive() - Control when things run with
isolate(),bindEvent()andreq() - Manage mutable state with
reactiveVal()andreactiveValues() - Debounce and throttle expensive reactions
- Diagnose infinite loops and unnecessary recomputation
The idea
In ordinary R, code runs top to bottom:
x <- 5
y <- x * 2 # y is 10
x <- 10
y # still 10 — y does not know x changedIn Shiny, reactive expressions form a graph. When a value changes, everything downstream is invalidated and recomputed the next time it is needed:
input$n ──▶ filtered() ──┬──▶ output$plot
└──▶ output$table
Change input$n and both outputs update. You never write “when the slider moves, redraw the plot” — you declare the dependency and Shiny works out the rest.
Two properties follow, and both matter:
- Laziness — a reactive only computes when something asks for its value. An output on a hidden tab does not recompute.
- Caching — a reactive computes once per invalidation, no matter how many consumers it has.
The three building blocks
| Returns a value | Has side effects | Lazy | |
|---|---|---|---|
reactive() |
Yes | No | Yes |
observe() |
No | Yes | No (eager) |
eventReactive() |
Yes | No | Yes, triggered |
observeEvent() |
No | Yes | Triggered |
reactive() — a value that updates
server <- function(input, output, session) {
filtered <- reactive({
adsl |>
filter(ARM == input$arm, AGE >= input$age[1], AGE <= input$age[2])
})
output$table <- renderDT(filtered())
output$plot <- renderPlot(ggplot(filtered(), aes(AGE)) + geom_histogram())
output$n <- renderText(nrow(filtered()))
}Call it with parentheses: filtered(). It computes once per change, and all three outputs share the result.
Use reactive() for: filtering, transforming, computing, querying — anything that produces a value.
observe() — a side effect
observe({
# Runs whenever input$arm changes
updateSelectInput(session, "site",
choices = sites_for_arm(input$arm))
})observe() is eager: it runs as soon as its dependencies change, whether or not anyone wants the result. It returns nothing useful.
Use observe() for: updating inputs, writing files, logging, showing notifications, modifying reactiveVals.
observe() to compute values
# WRONG
result <- NULL
observe({ result <<- expensive_calc(input$x) })
output$plot <- renderPlot(plot(result)) # race condition, may be stale
# RIGHT
result <- reactive(expensive_calc(input$x))
output$plot <- renderPlot(plot(result()))The <<- version breaks laziness and caching, and the order of execution is not guaranteed. If you find yourself assigning to a variable from inside observe(), you want reactive() or reactiveVal().
eventReactive() — compute on demand
results <- eventReactive(input$run, {
# Only runs when the Run button is clicked.
# Changes to input$n do NOT trigger it.
run_simulation(n = input$n, seed = input$seed)
})
output$plot <- renderPlot(plot(results()))Essential when a computation is expensive and inputs change frequently — the user sets three parameters and then clicks Run.
observeEvent() — side effect on demand
observeEvent(input$save, {
saveRDS(filtered(), "output.rds")
showNotification("Saved", type = "message")
})
observeEvent(input$reset, {
updateSelectInput(session, "arm", selected = "All")
updateSliderInput(session, "age", value = c(18, 90))
})Useful options:
observeEvent(input$go, { ... },
ignoreNULL = FALSE, # also fire when the value is NULL (default TRUE)
ignoreInit = TRUE, # do not fire on startup
once = TRUE, # fire only the first time
priority = 10 # higher runs first
)bindEvent()
The modern replacement for both eventReactive() and observeEvent():
# These are equivalent
results <- eventReactive(input$run, expensive())
results <- reactive(expensive()) |> bindEvent(input$run)
observeEvent(input$save, save_data())
observe(save_data()) |> bindEvent(input$save)
# And it works on outputs, which the old form could not do cleanly
output$plot <- renderPlot(ggplot(d(), aes(x)) + geom_point()) |>
bindEvent(input$draw)bindEvent() composes with bindCache() too, which is where it earns its keep — see the performance section below.
Controlling dependencies
isolate()
Read a value without depending on it:
observeEvent(input$submit, {
# Depends only on input$submit; the other values are read but not tracked
record <- list(
arm = isolate(input$arm),
age = isolate(input$age),
note = isolate(input$note)
)
append_record(record)
})Inside observeEvent() the event expression is the only dependency, so everything in the body is effectively isolated already. isolate() is most useful inside reactive() and observe().
req()
Stop quietly when a value is not ready:
output$plot <- renderPlot({
req(input$file) # stop until a file is uploaded
req(input$arm != "")
req(nrow(filtered()) > 0)
ggplot(filtered(), aes(AGE)) + geom_histogram()
})req() raises a silent condition that stops the reactive without an error message. It is the correct way to handle “not ready yet” — checking if (is.null(input$file)) return(NULL) produces an empty plot instead of nothing, which looks broken.
req(x) # stop if NULL, FALSE, "", or length 0
req(x, cancelOutput = TRUE) # keep the previous output visible
req(nchar(input$id) == 10) # any conditionvalidate() and need()
Show a message instead of stopping silently:
output$plot <- renderPlot({
validate(
need(input$file, "Please upload a dataset to begin."),
need(nrow(filtered()) > 0, "No subjects match the current filters."),
need(is.numeric(filtered()$AVAL), "AVAL must be numeric.")
)
ggplot(filtered(), aes(AVAL)) + geom_histogram()
})Use req() for “not yet” and validate() for “this cannot work, and here is why”.
Mutable state
Reactives are computed from inputs. Sometimes you need state that accumulates.
reactiveVal() — one value
server <- function(input, output, session) {
counter <- reactiveVal(0)
observeEvent(input$increment, {
counter(counter() + 1) # set by calling with an argument
})
observeEvent(input$reset, {
counter(0)
})
output$count <- renderText(counter()) # read by calling with none
}reactiveValues() — several
server <- function(input, output, session) {
state <- reactiveValues(
data = NULL,
filters = list(),
history = character()
)
observeEvent(input$upload, {
state$data <- read_csv(input$upload$datapath)
state$history <- c(state$history,
paste("Loaded", input$upload$name, Sys.time()))
})
observeEvent(input$undo, {
state$filters <- head(state$filters, -1)
})
output$summary <- renderPrint({
req(state$data)
summary(state$data)
})
}# INFINITE LOOP
observe({
x(x() + 1) # reads x, writes x -> invalidates itself
})Breaking the cycle:
observe({
isolate(x(x() + 1)) # read without depending
})
observeEvent(input$go, {
x(x() + 1) # depend on the button, not on x
})Symptoms: the browser hangs, CPU pegs at 100%, the R console floods. If it happens, look for a reactive that both reads and writes the same reactiveVal.
Performance
Caching
output$plot <- renderPlot({
expensive_plot(input$dataset, input$var)
}) |>
bindCache(input$dataset, input$var)Shiny stores the rendered result keyed on those values. A user who returns to a previous selection gets the cached plot instantly. Cache scope:
bindCache(input$x, cache = "session") # per user
bindCache(input$x, cache = "app") # shared across users (default)
shinyOptions(cache = cachem::cache_disk("./cache", max_size = 500e6))Only cache on values that fully determine the output. If the plot also depends on a database whose contents change, the cache key must include something that changes with it.
Debounce and throttle
# Wait 500ms after the user stops typing
search_term <- reactive(input$search) |> debounce(500)
# At most once per second, regardless
slider_value <- reactive(input$slider) |> throttle(1000)
filtered <- reactive({
adsl |> filter(str_detect(USUBJID, search_term()))
})Without debounce(), a text input fires a query per keystroke. With it, the query fires once when typing stops.
Do the work once
# BAD: three separate expensive reads
output$plot <- renderPlot(plot(expensive_query(input$x)))
output$table <- renderDT(expensive_query(input$x))
output$n <- renderText(nrow(expensive_query(input$x)))
# GOOD: one read, three consumers
data <- reactive(expensive_query(input$x))
output$plot <- renderPlot(plot(data()))
output$table <- renderDT(data())
output$n <- renderText(nrow(data()))This is the single most common performance fix in real Shiny apps.
Debugging reactivity
options(shiny.reactlog = TRUE)
runApp()
# In the browser: Ctrl/Cmd + F3
# Or after stopping: shiny::reactlogShow()reactlog draws the dependency graph and lets you step through every invalidation. When an output updates that you did not expect to, this shows you the path.
Cheaper instrumentation:
filtered <- reactive({
cli::cli_inform("filtered() recomputing at {Sys.time()}")
adsl |> filter(ARM == input$arm)
})If that message prints twice per change, you have a duplicated dependency.
Decision guide
Need a value?
├── Yes ──▶ Should it update automatically?
│ ├── Yes ──▶ reactive()
│ └── No, on a button ──▶ reactive() |> bindEvent(input$go)
└── No, a side effect ──▶ Should it run automatically?
├── Yes ──▶ observe()
└── No, on a button ──▶ observeEvent(input$go, ...)
Additional rules:
- Accumulating state →
reactiveVal()/reactiveValues() - Expensive and repeated →
bindCache() - Fires too often →
debounce()/throttle() - Not ready yet →
req() - Cannot work, tell the user →
validate(need(...))
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
observe() + <<- to compute |
Stale or racy values | reactive() |
Forgetting () on a reactive |
“object of type closure is not subsettable” | filtered() not filtered |
Reading and writing the same reactiveVal |
Infinite loop | isolate() or observeEvent() |
| Expensive work repeated per output | Slow app | One reactive(), many consumers |
No req() on startup |
Errors flash before inputs exist | req(input$x) |
observeEvent without ignoreInit |
Fires once at startup unexpectedly | ignoreInit = TRUE |
| Caching on incomplete keys | Stale results | Include every dependency in bindCache() |
Exercise 2.1 — Choose the right construct
For each requirement, name the construct:
- Filter a dataset by two dropdowns, used by a plot and a table
- Run a 30-second bootstrap when a button is clicked
- Write the current filter selections to a log file whenever they change
- Update the choices of a “site” dropdown when “country” changes
- Count how many times the user has downloaded a file
- Recompute a summary only when the user stops dragging a slider
Show solution
(a) reactive(). It returns a value with multiple consumers, so caching matters.
filtered <- reactive(filter(adsl, ARM == input$arm, SEX == input$sex))(b) eventReactive() or reactive() |> bindEvent(). Returns a value, must not fire on every parameter tweak.
boot <- reactive(bootstrap(data(), n = input$n)) |> bindEvent(input$run)(c) observe(). A side effect with no return value, should happen automatically.
observe({
log_selection(input$arm, input$sex)
})(d) observeEvent(). A side effect (updating an input) with an explicit trigger.
observeEvent(input$country, {
updateSelectInput(session, "site", choices = sites_in(input$country))
})(e) reactiveVal() for the state, incremented from observeEvent().
n_downloads <- reactiveVal(0)
observeEvent(input$download, n_downloads(n_downloads() + 1))(f) debounce(). The slider fires continuously while dragging.
slider_val <- reactive(input$slider) |> debounce(400)
summary_stat <- reactive(compute_summary(data(), slider_val()))sliderInput() also accepts nothing to make it fire only on release — debounce() is the general solution and works for text inputs too.
Exercise 2.2 — Fix a broken server
This server has four distinct reactivity problems. Find and fix them all.
server <- function(input, output) {
data <- NULL
observe({
data <<- read_csv(input$file$datapath)
})
output$plot <- renderPlot({
d <- data[data$arm == input$arm, ]
ggplot(d, aes(age)) + geom_histogram()
})
output$table <- renderTable({
d <- data[data$arm == input$arm, ]
head(d, 20)
})
observe({
updateSelectInput(session, "arm", choices = unique(data$arm))
})
}Show solution
Problems:
observe()+<<-to store data. No caching, no guaranteed ordering, and the outputs do not take a reactive dependency ondata, so they may render before it is loaded or fail to update after.- No
req().input$fileisNULLat startup, soread_csv(NULL$datapath)errors immediately. - Duplicated filtering logic in both outputs.
sessionis missing from the signature, soupdateSelectInput(session, ...)errors.
A fifth: the final observe() runs on every invalidation of data, which is correct, but should be an observeEvent() on the upload for clarity.
server <- function(input, output, session) {
# 1 + 2: a reactive with a guard
raw_data <- reactive({
req(input$file)
readr::read_csv(input$file$datapath, show_col_types = FALSE)
})
# 4: update the dropdown when new data arrives
observeEvent(raw_data(), {
updateSelectInput(session, "arm",
choices = sort(unique(raw_data()$arm)),
selected = character(0))
})
# 3: filtering written once
filtered <- reactive({
req(input$arm)
dplyr::filter(raw_data(), arm == input$arm)
})
output$plot <- renderPlot({
validate(need(nrow(filtered()) > 0, "No records for this arm."))
ggplot(filtered(), aes(age)) +
geom_histogram(binwidth = 5, fill = "#16355e", colour = "white") +
theme_minimal(base_size = 13)
})
output$table <- renderTable(head(filtered(), 20))
}The resulting graph is explicit:
input$file ──▶ raw_data() ──┬──▶ observeEvent ──▶ updateSelectInput
└──▶ filtered() ──┬──▶ output$plot
└──▶ output$table
input$arm ───────────────┘
Every arrow is a declared dependency, and nothing is stored outside the reactive system.
Exercise 2.3 — Undo/redo state
Implement a filter history: each time the user applies a filter it is pushed onto a stack, and Undo/Redo buttons move through it. Show the current filter as text.
Show solution
library(shiny)
server <- function(input, output, session) {
state <- reactiveValues(
history = list(list(arm = "All", sex = "All")), # start with a default
position = 1L
)
current <- reactive({
state$history[[state$position]]
})
# Apply: truncate any redo branch, then push
observeEvent(input$apply, {
new_filter <- list(arm = input$arm, sex = input$sex)
# Do not push a duplicate of the current state
if (identical(new_filter, current())) return()
state$history <- c(state$history[seq_len(state$position)], list(new_filter))
state$position <- length(state$history)
})
observeEvent(input$undo, {
if (state$position > 1L) state$position <- state$position - 1L
})
observeEvent(input$redo, {
if (state$position < length(state$history)) state$position <- state$position + 1L
})
# Keep the inputs in sync when navigating history
observeEvent(current(), {
updateSelectInput(session, "arm", selected = current()$arm)
updateSelectInput(session, "sex", selected = current()$sex)
}, ignoreInit = TRUE)
# Disable buttons at the ends of the stack
observe({
shinyjs::toggleState("undo", condition = state$position > 1L)
shinyjs::toggleState("redo", condition = state$position < length(state$history))
})
output$current_filter <- renderText({
sprintf("Step %d of %d — arm: %s, sex: %s",
state$position, length(state$history),
current()$arm, current()$sex)
})
# Downstream consumers use current(), not input$arm directly
filtered <- reactive({
d <- adsl
if (current()$arm != "All") d <- dplyr::filter(d, ARM == current()$arm)
if (current()$sex != "All") d <- dplyr::filter(d, SEX == current()$sex)
d
})
output$table <- DT::renderDT(filtered())
}Three design points worth noting:
- Truncating the redo branch on a new apply is what makes this behave like every other undo stack. Without it, applying a filter after undoing leaves an orphaned future.
- Downstream code reads
current(), notinput$arm. If the plot depended on the inputs directly, undo would update the text but not the plot. - The sync observer needs
ignoreInit = TRUE, otherwise it fires at startup and overwrites the user’s first selection.
identical() guard prevents the stack filling with duplicates when the user clicks Apply repeatedly.
Recap
- Reactives form a graph; changes invalidate downstream nodes, which recompute lazily
reactive()returns a value and caches;observe()performs side effects eagerlybindEvent()is the modern trigger;bindCache()the modern cachereq()for “not ready”,validate(need())for “cannot work, here is why”reactiveVal()/reactiveValues()for accumulating state- Reading and writing the same reactive value creates an infinite loop
- One
reactive()feeding many outputs is the main performance fix reactlog(Ctrl/Cmd+F3) when the graph does not do what you expect
Next: Inputs and outputs.