Skip to content

Refreshable reactivePoll #4369

Open
Open

Description

@Upipa

In my Shiny applications, I often encounter the need to keep a reactive object (e.g., a table) in sync with an external data source over time, while also updating it immediately after a user-triggered change.

A typical example is a database table: I want to display an up-to-date version of the data to the user. For this purpose, Shiny already provides reactivePoll(), which allows periodic checks for updates and refreshes the reactive accordingly.

However, in most real-world scenarios where I use reactivePoll(), I also need a mechanism to trigger an immediate refresh after a user modifies the underlying data. For instance, if a user inserts a new record into the database, they naturally expect to see it reflected in the UI right away.

One possible approach is to set a very short polling interval, but this is often not feasible. In practice, multiple users may be connected to the same worker, and frequent polling can lead to excessive database queries and performance degradation (I have personally experienced this causing the app to freeze). For this reason, I typically use a relatively long polling interval (e.g., ~100 seconds), which balances freshness and performance.

The downside is that, with this setup, a user may need to wait over a minute to see changes they just made, resulting in a poor user experience and a perception that the app is unresponsive.

To address this, I have repeatedly implemented workarounds that combine:

  • periodic polling (at a reasonable interval), and
  • a manual trigger to refresh the reactive immediately after a user update.

Conceptually, what I need is a refreshable reactivePoll(): a version that can be invalidated programmatically, without waiting for the next polling cycle.

As a proof of concept, I implemented a small extension of reactivePoll() that returns a list instead of a single reactive:

  • value: the reactive expression (same as standard reactivePoll())
  • refresh(): a function that forces a refresh by updating the internal “cookie” used for change detection

Here is the implementation:

refreshableReactivePoll <- function(
  intervalMillis,
  session,
  checkFunc,
  valueFunc
) {
  intervalMillis <- shiny:::coerceToFunc(intervalMillis)
  rv <- reactiveValues(cookie = isolate(checkFunc()))
  re_finalized <- FALSE
  env <- environment()

  o <- observe({
    if (re_finalized) {
      o$destroy()
      rm(o, envir = env)
      return()
    }
    rv$cookie <- checkFunc()
    invalidateLater(intervalMillis(), session)
  })

  re <- reactive(
    {
      rv$cookie
      valueFunc()
    },
    label = NULL
  )
  reg.finalizer(attr(re, "observable"), function(e) {
    re_finalized <<- TRUE
  })

  re <- list(
    value = re,
    refresh = function() {
      rv$cookie <- checkFunc()
    }
  )

  on.exit(rm(re))
  return(re)
}

Updating the “cookie” rather than the reactive value directly avoids redundant updates when the next polling cycle runs, since the state is already aligned. This allows the reactive to be refreshed both periodically and on demand (e.g., immediately after a database write).

I am not aware of an officially recommended pattern for this use case, but this need arises frequently in my applications. It feels like Shiny currently lacks a clean, built-in way to handle this pattern efficiently and systematically.

I believe that adding native support for a programmatically refreshable reactivePoll() (or a similar abstraction) would be a valuable enhancement for many real-world applications.

Activity

  1. cpsievert commented on Apr 1, 2026

    @cpsievert
    Collaborator

    Thanks for the well-written issue, @Upipa — this is a common and legitimate pattern.

    The good news is that this already works with the existing reactivePoll() API, no changes needed. The key insight is that checkFunc runs inside a reactive context (an observe), so if it reads a reactive value, changing that value will immediately re-trigger the check (and thus refresh the data) without waiting for the next polling cycle.

    Here's the pattern:

    server <- function(input, output, session) {
      # A reactive value that serves as a manual trigger
      refresh_trigger <- reactiveVal(NULL)
    
      data <- reactivePoll(
        intervalMillis = 100000,
        session = session,
        checkFunc = function() {
          # Take a dependency on the manual trigger
          refresh_trigger()
          # Your actual check logic
          dbGetQuery(con, "SELECT max(updated_at) FROM my_table")
        },
        valueFunc = function() {
          dbGetQuery(con, "SELECT * FROM my_table")
        }
      )
    
      observeEvent(input$save, {
        # Write to the database
        dbExecute(con, "INSERT INTO my_table ...")
        # Force an immediate refresh
        refresh_trigger(runif(1))
      })
    
      output$table <- renderTable({
        data()
      })
    }

    When refresh_trigger() changes, the observer that drives reactivePoll re-executes checkFunc() immediately. The new check value updates the internal cookie, which invalidates the downstream reactive — giving you the instant refresh you're after. The periodic polling continues to work as usual for changes made by other users/processes.

    This is admittedly not obvious from the docs, so we could improve the ?reactivePoll documentation to mention this pattern. But I don't think a new function or API change is warranted here since the existing mechanism handles it cleanly.

  2. gadenbuie commented on Apr 1, 2026

    @gadenbuie
    Member

    This is admittedly not obvious from the docs, so we could improve the ?reactivePoll documentation to mention this pattern.

    lol I also took a pass at this and completely missed this detail. We should definitely mention this in the docs, it does not look from the outside like checkFunc would be evaluated in a reactive context, although it makes sense now that I've seen it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions