Skip to content

bug: Dangling reactivity - reactive objects persist after dynamic UI is removed #2207

Description

@schloerke

Summary

Hat tip to @khusmann for clearly articulating this problem.

When dynamic UI is removed (via remove_ui(), @render.ui re-rendering, or toggling visibility), the server-side reactive objects associated with that UI are not cleaned up. This creates three types of dangling state:

  1. Dangling effects -- reactive.effect objects created for dynamic UI keep firing even after the UI is removed from the DOM.
  2. Dangling calcs -- reactive.calc objects persist in memory with stale values. Calc_ has no destroy() method at all.
  3. Dangling input values -- input.foo() still returns the last value for inputs that no longer exist in the DOM.

Reproduction

Image

Open the demo app in Shinylive

  1. Click Create Panel to add 2-3 panels. Each creates a module with an auto-incrementing reactive.effect, a reactive.calc, and a dynamic input_text.
  2. Uncheck Show dynamic UI on a panel -- the sidebar monitor shows the effect count still incrementing and the input value still present.
  3. Click Remove this panel -- the entry turns red and shows [REMOVED], but the effect count keeps going up and the input/calc values persist.

Root Cause

The server has no concept of reactive object ownership or scoping tied to UI lifecycle:

  • insert_ui() / remove_ui() are purely client-side DOM operations. The server sends a message and forgets about it.
  • Module servers create reactive objects that register with the session, but nothing tracks which module "owns" which reactive objects.
  • There is no "reactive scope" that groups reactive objects and can destroy them as a unit.
  • Calc_ lacks basic lifecycle management -- no destroy(), no session.on_ended() callback.

What IS cleaned up today

Mechanism How
Output effects when re-registered with same ID Outputs.remove() calls effect.destroy()
All Effect_ on session end Each registers session.on_ended(self.destroy)
Client-side input/output bindings shinyUnbindAll(el) before DOM replacement
Output suspension when hidden _manage_hidden() suspends (but does NOT destroy)

What is NOT cleaned up

Mechanism
reactive.effect from module server after remove_ui
reactive.calc (no destroy() method exists)
Server-side input.* values for removed DOM inputs
Any server-side state tracking for insert_ui / remove_ui

Prior Art

py-shiny

R Shiny (rstudio/shiny)

This is a long-standing problem in R Shiny as well:

  • rstudio/shiny#2281 -- The canonical issue: insertUI/callModule to add, removeUI/??? to remove. No mechanism to deactivate a module server instance.
  • rstudio/shiny#825 -- "Reactive subDomains" proposal (2016). Proposes createSubDomain() where ending the subdomain destroys all reactive objects created within it.
  • rstudio/shiny#2374 -- Request to delete server-side input values on removeUI().
  • rstudio/shiny#3812 -- Add hooks around dynamic UI lifecycle.

Current Workaround

Users must manually track every reactive.effect and call .destroy() on removal. This doesn't scale, doesn't work for reactive.calc (no destroy method), and doesn't clean up input values.

Possible Directions (not prescriptive)

  • Add destroy() to Calc_
  • Reactive scoping / ownership tracking (a la Proposal: reactive subDomains rstudio/shiny#825 subDomains)
  • Server-side awareness of which inputs/outputs belong to which dynamic UI context
  • Coordination between remove_ui / @render.ui and server-side cleanup
  • Module-level teardown mechanism

Activity

  1. changed the title [-]bug: Dangling reactivity: reactive objects persist after dynamic UI is removed[/-] [+]bug: Dangling reactivity - reactive objects persist after dynamic UI is removed[/+] on Apr 3, 2026
  2. gadenbuie commented on Apr 3, 2026

    @gadenbuie
    Collaborator

    Also related, I started some work that makes it possible to clean up inputs:

  3. schloerke commented on Apr 3, 2026

    @schloerke
    CollaboratorAuthor

    I'm going to give this a go via session_proxy.destroy() which will work for inputs/outputs registered within a module. All downstream reactives from inputs will also be destroyed. ... It might also just be that session_proxy tracks all of the reactive objects made under it's session (as effect + invalidatelater does not have an upstream input / output that is easily tracked) 🤷‍♂️

    It'd also add .destroy() methods for input values, reactive.value, reactive.calc, and session.

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