Reactive programming

Lesson 2 — R Shiny

Lesson 2 of 11 Intermediate ~100 min

Learning objectives

  • Describe the reactive graph and how invalidation propagates
  • Choose correctly between reactive(), observe() and eventReactive()
  • Control when things run with isolate(), bindEvent() and req()
  • Manage mutable state with reactiveVal() and reactiveValues()
  • 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 changed

In 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.

WarningDo not use 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 condition

validate() 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)
  })
}
ImportantInfinite loops
# 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:

  1. Filter a dataset by two dropdowns, used by a plot and a table
  2. Run a 30-second bootstrap when a button is clicked
  3. Write the current filter selections to a log file whenever they change
  4. Update the choices of a “site” dropdown when “country” changes
  5. Count how many times the user has downloaded a file
  6. 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()))
Note that 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:

  1. observe() + <<- to store data. No caching, no guaranteed ordering, and the outputs do not take a reactive dependency on data, so they may render before it is loaded or fail to update after.
  2. No req(). input$file is NULL at startup, so read_csv(NULL$datapath) errors immediately.
  3. Duplicated filtering logic in both outputs.
  4. session is missing from the signature, so updateSelectInput(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(), not input$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.
The 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 eagerly
  • bindEvent() is the modern trigger; bindCache() the modern cache
  • req() 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.

Back to top