Shiny App Modularization - Research Notes
Experiment Overview
| Item |
Details |
| Date |
2026-02-16 |
| Goal |
Break a 5,397-line monolithic Shiny app.R into modules without losing functionality |
| Environment |
R 4.x, Shiny 1.12.1, plotly 4.12.0, htmlwidgets 1.6.4, sf, ggplot2 |
| Status |
Success |
Context
The Islet Explorer app grew to 5,400 lines in a single app.R. Modularization was needed for maintainability, but interactive features (plotly click-to-segmentation, cross-tab communication) broke during extraction.
Verified Workflow
1. Extraction Order (least to most coupled)
00_globals.R - library loads, constants, feature flags
- Pure utility functions (no Shiny deps):
utils_safe_join.R, utils_stats.R, data_loading.R
- Domain helpers:
segmentation_helpers.R, viewer_helpers.R, ai_helpers.R
- Self-contained modules: AI Assistant
- Data-consuming modules: Statistics, Viewer
- Interactive modules: Plot (click handler), Trajectory (click handler + H5AD)
2. Plotly Click Events in Modules
# In module server - namespace the source string
ns <- session$ns
gg <- ggplotly(p, source = ns("my_source"))
gg <- gg %>% event_register("plotly_click")
# Click handler - use matching ns() source
observeEvent(event_data("plotly_click", source = ns("my_source")), {
d <- event_data("plotly_click", source = ns("my_source"))
# ...
})
3. Root-Level Shared Outputs for Cross-Module Rendering
When multiple modules need to render to the same output (e.g., segmentation plot used by both a modal and an embedded panel), define the renderPlot at the ROOT level in app.R, not inside any module.
# app.R server - root level
output$shared_plot <- renderPlot({
req(shared_reactive())
render_function(shared_reactive())
})
# Module A - modal references root-level output (NO ns() prefix)
showModal(modalDialog(plotOutput("shared_plot")))
# Module B - embedded panel references root-level output (NO ns() prefix)
output$my_panel <- renderUI({
plotOutput("shared_plot")
})
5. Non-namespaced output deconfliction across modules
When multiple modules emit non-namespaced output IDs (shared root-level renders), pass active_tab reactive and guard renderUI to prevent duplicate DOM IDs.
4. ggplot2 vs sf Namespace
# WRONG - coord_sf and geom_sf are NOT sf exports
sf::coord_sf(...) # Error: 'coord_sf' is not an exported object from 'namespace:sf'
sf::geom_sf(...) # Works but wrong namespace
# CORRECT - these are ggplot2 functions
ggplot2::coord_sf(xlim = ..., ylim = ..., expand = FALSE)
ggplot2::geom_sf(data = polygons, fill = NA, color = "blue")
Failed Attempts (Critical)
| Attempt |
Why it Failed |
Lesson Learned |
Module-scoped plotOutput(ns("seg_view")) for modal |
showModal inserts at DOM body level; module-scoped renderPlot didn't reliably bind to the output element |
Use root-level shared outputs for cross-module rendering |
Non-namespaced plotly source source = "plot_scatter" in module |
event_data() couldn't find the event because plotly's JS uses the source string to namespace Shiny.setInputValue() calls |
Always use ns("source_name") for plotly sources inside modules |
sf::coord_sf() in extracted helper file |
coord_sf is a ggplot2 function, not sf. Original app used bare coord_sf() (ggplot2 was loaded) which worked, but explicit namespacing exposed the wrong package |
When adding explicit namespaces during extraction, verify which package actually exports each function |
Adding event_register("plotly_click") to fix click detection |
plotly_click is already in default shinyEvents so event_register wasn't the fix |
The real issue was the source string namespacing, not event registration |
Debugging with JS shiny:inputchanged listener and R showNotification |
Helpful for confirming click handlers fire, but didn't reveal the downstream rendering issue |
Start by comparing with the working original code rather than adding incremental diagnostics |
Both modules emit non-namespaced plotOutput() IDs |
When selected_islet() is shared between Plot and Trajectory, both segmentation_viewer_panel renderUIs fire simultaneously, creating duplicate DOM elements with the same ID. Shiny can only bind one → other greys out or becomes orphaned |
Pass active_tab reactive (from reactive(input$tabs)) to each module; guard renderUI with if (active_tab() != "MyTab") return(NULL) so only the visible tab's panel renders |
| Assuming only visible tab's renderUI fires |
Shiny renderUI fires based on reactive dependencies, NOT tab visibility. Even hidden tabs execute renderUI when their inputs change |
Never assume tab hiding prevents server-side rendering; use explicit tab-active guards for any output with non-namespaced IDs |
Key Insights
- Shiny's
R/ directory is auto-sourced alphabetically before global.R. Prefix globals file with 00_ to ensure it loads first.
showModal from inside a module works fine for sending the modal, but the plotOutput inside the modal must match a renderPlot that Shiny can bind. Root-level outputs are safest for shared rendering.
reactiveVal objects can be passed as module parameters for cross-module communication (e.g., selected_islet, forced_image).
- When extracting monolithic code, grep for bare function calls (like
coord_sf, geom_sf) and verify which package exports them before adding pkg:: prefixes.
- For diverging data (zero-centered z-scores), use
scale_color_gradient2() with limits and scales::squish, not sequential colormaps.
- Non-namespaced output deconfliction: When multiple modules emit the same non-namespaced
plotOutput() / tableOutput() IDs (required for root-level shared renders), pass active_tab = reactive(input$tabs) to each module and guard the renderUI that emits those IDs. Pattern: if (active_tab() != "Plot") return(NULL). This prevents duplicate DOM IDs that cause Shiny to grey out / orphan one binding.
References
1---2name: shiny-modularization3description: Patterns and pitfalls for modularizing large Shiny apps with plotly, sf, and cross-module reactives4---56# Shiny App Modularization - Research Notes78## Experiment Overview9| Item | Details |10|------|---------|11| **Date** | 2026-02-16 |12| **Goal** | Break a 5,397-line monolithic Shiny app.R into modules without losing functionality |13| **Environment** | R 4.x, Shiny 1.12.1, plotly 4.12.0, htmlwidgets 1.6.4, sf, ggplot2 |14| **Status** | Success |1516## Context17The Islet Explorer app grew to 5,400 lines in a single app.R. Modularization was needed for maintainability, but interactive features (plotly click-to-segmentation, cross-tab communication) broke during extraction.1819## Verified Workflow2021### 1. Extraction Order (least to most coupled)221. `00_globals.R` - library loads, constants, feature flags232. Pure utility functions (no Shiny deps): `utils_safe_join.R`, `utils_stats.R`, `data_loading.R`243. Domain helpers: `segmentation_helpers.R`, `viewer_helpers.R`, `ai_helpers.R`254. Self-contained modules: AI Assistant265. Data-consuming modules: Statistics, Viewer276. Interactive modules: Plot (click handler), Trajectory (click handler + H5AD)2829### 2. Plotly Click Events in Modules30```r31# In module server - namespace the source string32ns <- session$ns33gg <- ggplotly(p, source = ns("my_source"))34gg <- gg %>% event_register("plotly_click")3536# Click handler - use matching ns() source37observeEvent(event_data("plotly_click", source = ns("my_source")), {38 d <- event_data("plotly_click", source = ns("my_source"))39 # ...40})41```4243### 3. Root-Level Shared Outputs for Cross-Module Rendering44When multiple modules need to render to the same output (e.g., segmentation plot used by both a modal and an embedded panel), define the `renderPlot` at the ROOT level in app.R, not inside any module.4546```r47# app.R server - root level48output$shared_plot <- renderPlot({49 req(shared_reactive())50 render_function(shared_reactive())51})5253# Module A - modal references root-level output (NO ns() prefix)54showModal(modalDialog(plotOutput("shared_plot")))5556# Module B - embedded panel references root-level output (NO ns() prefix)57output$my_panel <- renderUI({58 plotOutput("shared_plot")59})60```6162### 5. Non-namespaced output deconfliction across modules63When multiple modules emit non-namespaced output IDs (shared root-level renders), pass `active_tab` reactive and guard `renderUI` to prevent duplicate DOM IDs.6465### 4. ggplot2 vs sf Namespace66```r67# WRONG - coord_sf and geom_sf are NOT sf exports68sf::coord_sf(...) # Error: 'coord_sf' is not an exported object from 'namespace:sf'69sf::geom_sf(...) # Works but wrong namespace7071# CORRECT - these are ggplot2 functions72ggplot2::coord_sf(xlim = ..., ylim = ..., expand = FALSE)73ggplot2::geom_sf(data = polygons, fill = NA, color = "blue")74```7576## Failed Attempts (Critical)7778| Attempt | Why it Failed | Lesson Learned |79|---------|---------------|----------------|80| Module-scoped `plotOutput(ns("seg_view"))` for modal | `showModal` inserts at DOM body level; module-scoped `renderPlot` didn't reliably bind to the output element | Use root-level shared outputs for cross-module rendering |81| Non-namespaced plotly source `source = "plot_scatter"` in module | `event_data()` couldn't find the event because plotly's JS uses the source string to namespace `Shiny.setInputValue()` calls | Always use `ns("source_name")` for plotly sources inside modules |82| `sf::coord_sf()` in extracted helper file | `coord_sf` is a ggplot2 function, not sf. Original app used bare `coord_sf()` (ggplot2 was loaded) which worked, but explicit namespacing exposed the wrong package | When adding explicit namespaces during extraction, verify which package actually exports each function |83| Adding `event_register("plotly_click")` to fix click detection | `plotly_click` is already in default `shinyEvents` so `event_register` wasn't the fix | The real issue was the source string namespacing, not event registration |84| Debugging with JS `shiny:inputchanged` listener and R `showNotification` | Helpful for confirming click handlers fire, but didn't reveal the downstream rendering issue | Start by comparing with the working original code rather than adding incremental diagnostics |85| Both modules emit non-namespaced `plotOutput()` IDs | When `selected_islet()` is shared between Plot and Trajectory, both `segmentation_viewer_panel` renderUIs fire simultaneously, creating duplicate DOM elements with the same ID. Shiny can only bind one → other greys out or becomes orphaned | Pass `active_tab` reactive (from `reactive(input$tabs)`) to each module; guard renderUI with `if (active_tab() != "MyTab") return(NULL)` so only the visible tab's panel renders |86| Assuming only visible tab's renderUI fires | Shiny `renderUI` fires based on reactive dependencies, NOT tab visibility. Even hidden tabs execute renderUI when their inputs change | Never assume tab hiding prevents server-side rendering; use explicit tab-active guards for any output with non-namespaced IDs |8788## Key Insights8990- Shiny's `R/` directory is auto-sourced alphabetically before `global.R`. Prefix globals file with `00_` to ensure it loads first.91- `showModal` from inside a module works fine for sending the modal, but the `plotOutput` inside the modal must match a `renderPlot` that Shiny can bind. Root-level outputs are safest for shared rendering.92- `reactiveVal` objects can be passed as module parameters for cross-module communication (e.g., `selected_islet`, `forced_image`).93- When extracting monolithic code, grep for bare function calls (like `coord_sf`, `geom_sf`) and verify which package exports them before adding `pkg::` prefixes.94- For diverging data (zero-centered z-scores), use `scale_color_gradient2()` with `limits` and `scales::squish`, not sequential colormaps.95- **Non-namespaced output deconfliction**: When multiple modules emit the same non-namespaced `plotOutput()` / `tableOutput()` IDs (required for root-level shared renders), pass `active_tab = reactive(input$tabs)` to each module and guard the `renderUI` that emits those IDs. Pattern: `if (active_tab() != "Plot") return(NULL)`. This prevents duplicate DOM IDs that cause Shiny to grey out / orphan one binding.9697## References98- [Shiny Modules](https://shiny.posit.co/r/articles/improve/modules/)99- [Plotly event_data in modules](https://plotly-r.com/linking-views-with-shiny.html)100- Islet Explorer: `app/shiny_app/` in this repository