If you’ve done extensive development in Shiny, you know the pain. For simple dashboards, Shiny works great: you define your UI layout, hook up a reactive data pipeline between your inputs and outputs, and ship it. Your client is happy.
But then the requests start coming in.
Populate this dropdown from the database.
Show different help text depending on which tab they’re on.
No problem, you say, and wire up some uiOutput() / renderUI() pairs. The client is happy, but then they want more.
Let users add and remove their own cards.
Each card should have its own controls they can tweak.
Ok, so now you’re reaching for insertUI() and removeUI() to manage dynamic elements…
…But the inserted UI needs to be reactive, so you add nested observers — and now you’re manually managing their lifecycle to avoid phantom clicks, memory leaks, and other surprising behavior. You start offloading what you can into the client by using shinyjs to run jQuery expressions client-side.
Pretty soon, your project is a brittle mess of workarounds and hacks. You’re writing more Javascript than R now. Wasn’t Shiny supposed to save you from that?
I call this Shiny’s “complexity wall” — and the more dynamic your UI, the harder you hit it.
The Root Cause
The standard advice for managing complexity in Shiny is to use Shiny Modules. And modules genuinely help — they encapsulate local state, take reactives in, return reactives out.
But they only go so far. A Shiny Module isn’t a single entity: it’s two functions (one UI, one server) that must be invoked in separate places. So it gets messy when you want to compose, conditionally render, or iterate over a list of them.
The deeper issue is the UI/server split itself: structure gets declared in one place, behavior in another. When structure has to react to state, you’re forced to generate UI from the server (via renderUI or insertUI) and wire up reactive behavior for things that didn’t exist a moment ago.
Server code ends up generating UI that references other server code. That’s when the workarounds start piling up.
What Shiny Got Right
Shiny first shipped in 2012, when “the server owns all the state, the UI is HTML it ships to the browser” was a clear and defensible choice. The patterns that would reframe it hadn’t arrived yet — and neither had the expectations that would demand them.
In that context, Shiny got half of modern UI right — and did it early. Its reactive primitives auto-track dependencies and propagate changes, the same core idea behind the “signals” model Solid.js popularized almost a decade later.
What Shiny missed was the other half: the component model that React would later crystallize. A component owns its structure, state, and behavior all in one place — no separate UI and behavior halves wired together by string ID. That’s what makes components composable: you can pass them around, nest them, iterate over them, conditionally render them, and still reason about them locally.
Without one, dynamic UI becomes a fight — building component-like patterns on a framework that was never designed for them.
A Way Forward
This is why I built irid. It’s an R package that brings fine-grained, component-based reactivity to Shiny — without leaving the Shiny ecosystem.
In irid, a component is a function that returns a tag tree. State lives right next to the markup that uses it. Any tag attribute can be made reactive by passing a function instead of a value. There are no string IDs to manage, no separate UI and server definitions to keep in sync.
Here’s what a simple counter looks like:
library(irid)
Counter <- function() {
count <- reactiveVal(0)
tags$div(
tags$p("Count: ", count),
tags$button(
"Increment",
disabled = \() count() >= 10,
onClick = \(ev) count(count() + 1)
)
)
}
iridApp(Counter)
Three things to notice here: count appears as a reactive text child inside tags$p(), the button’s disabled attribute is a function that re-evaluates whenever count changes, and onClick is wired directly on the tag — no observers, no input / output IDs, no updateActionButton() or observeEvent().
If you’re a Shiny developer, you might recoil at the idea of reactive values scattered throughout your markup — won’t this explode the DOM on every change? Reset cursor position, lose focus, restart animations?
That’s renderUI()’s world. irid’s updates are surgical: the browser gets “set this value here” and applies it to the existing DOM node in place. Nothing around it moves, and only the changed value crosses the wire.
No UI/server split, no string IDs, no lifecycle to manage — and once that’s gone, a whole category of Shiny pain points just unravels. They don’t need to be solved; they stop existing. Let’s look at a few.
What This Unlocks
Composing Components
Say you want two counters side by side with a running total, each with its own Reset button, plus a Reset All button that disables itself when both counts are zero. In Shiny, the standard way to do this is with modules. Each counter gets a UI function and a server function, linked by a namespaced ID:
library(shiny)
library(bslib)
counterUI <- function(id, label) {
ns <- NS(id)
card(
card_header(label),
card_body(
tags$h2(class = "text-center", textOutput(ns("display"))),
sliderInput(ns("value"), NULL, min = 0, max = 100, value = 0),
actionButton(ns("reset"), "Reset")
)
)
}
counterServer <- function(id) {
moduleServer(id, function(input, output, session) {
output$display <- renderText(paste("Count:", input$value))
observeEvent(input$reset, {
updateSliderInput(session, "value", value = 0)
})
observe({
shinyjs::toggleState("reset", input$value != 0)
})
list(
value = reactive(input$value),
reset = function() updateSliderInput(session, "value", value = 0)
)
})
}
ui <- page_fluid(
shinyjs::useShinyjs(),
tags$h3(class = "text-center", textOutput("total")),
layout_columns(
counterUI("a", "A"),
counterUI("b", "B")
),
actionButton("reset_all", "Reset All")
)
server <- function(input, output, session) {
count_a <- counterServer("a")
count_b <- counterServer("b")
output$total <- renderText(paste("Total:", count_a$value() + count_b$value()))
observeEvent(input$reset_all, {
count_a$reset()
count_b$reset()
})
observe({
shinyjs::toggleState("reset_all", count_a$value() != 0 || count_b$value() != 0)
})
}
shinyApp(ui, server)
Notice the interface the server has to hand-craft for the parent: a $value reactive to read the count, and a $reset function wrapping updateSliderInput() so the parent can force a reset. Every parent-child interaction is a bespoke entry on that returned list. And because updateActionButton() can’t set disabled, we have to reach for shinyjs::toggleState() to disable the Reset All button.
Here’s the same thing in irid:
library(irid)
library(bslib)
Counter <- function(label, count) {
card(
card_header(label),
card_body(
tags$h2(class = "text-center", \() paste("Count:", count())),
tags$input(
type = "range", min = 0, max = 100,
value = count,
onInput = \(event) count(event$valueAsNumber)
),
tags$button(
class = "btn btn-outline-secondary btn-sm",
disabled = \() count() == 0,
onClick = \() count(0),
"Reset"
)
)
)
}
App <- function() {
count_a <- reactiveVal(0)
count_b <- reactiveVal(0)
page_fluid(
tags$h3(class = "text-center", \() paste("Total:", count_a() + count_b())),
layout_columns(
Counter("A", count_a),
Counter("B", count_b)
),
tags$button(
class = "btn btn-outline-primary",
disabled = \() count_a() == 0 && count_b() == 0,
onClick = \() { count_a(0); count_b(0) },
"Reset All"
)
)
}
iridApp(App)
Counter is just a function that takes a reactiveVal and returns a tag tree. The parent owns the state, passes it down, and the child reads and writes it directly through the same reactive reference. The Reset All button is short because the parent already holds both counts — no return-value plumbing, no updateSliderInput() reaching back into the module, no shinyjs::toggleState() observer wired up on the side. disabled is just a reactive tag attribute, like any other.
Look closer at the slider itself: its value reads from count and its onInput writes back to the same reactive. The input doesn’t own its state — it’s just a view of the parent’s reactiveVal. More on that in the next section.
Controlled Inputs
Say you want a temperature converter: a Celsius slider and a Fahrenheit slider, where editing either one updates the other. Two inputs, one underlying value.
In Shiny, each sliderInput() owns its own state. To keep them in sync you write two observeEvent()s — one watching Celsius, one watching Fahrenheit — each calling updateSliderInput() on the other. Now you have a feedback loop: updating Fahrenheit fires Celsius’s observer, which updates Fahrenheit, which fires its observer again. The usual fix is freezeReactiveValue() or an isolate() guard, plus careful rounding so the values settle. It works, but you’re writing plumbing to stop the inputs from fighting each other.
The root problem is that the input is the state. You can’t have two inputs share one value — you can only have two values that chase each other.
In irid, an input’s value attribute can be bound to a reactiveVal, and that reactiveVal becomes the single source of truth. The input displays it, writes to it, and re-renders when it changes — no feedback loop, because there’s only one value:
library(irid)
c_to_f <- function(c) round(c * 9 / 5 + 32, 1)
f_to_c <- function(f) round((f - 32) * 5 / 9, 1)
Thermometer <- function(label, value, on_change, min, max) {
tags$div(
tags$label(label),
tags$input(
type = "range", min = min, max = max,
value = value,
onInput = \(event) on_change(event$valueAsNumber)
)
)
}
App <- function() {
celsius <- reactiveVal(20)
fahrenheit <- \() c_to_f(celsius())
tags$div(
Thermometer("Celsius", celsius, celsius, -40, 60),
Thermometer("Fahrenheit", fahrenheit, \(f) celsius(f_to_c(f)), -40, 140)
)
}
iridApp(App)
Celsius is the canonical value. Fahrenheit is a function that derives from it. The Celsius thermometer reads and writes celsius directly; the Fahrenheit thermometer reads the derived value and writes back through f_to_c(). Both stay in sync because they’re views of the same underlying state, not independent inputs that have to be reconciled.
This also makes re-hydration trivial. Because the reactiveVal is the state, restoring a saved session is just calling celsius(saved_value) — the inputs follow. No updateSliderInput() calls, no Shiny bookmark gymnastics to thread state back through each widget’s own internal store.
Dynamic UI
I wrote about this pain in detail previously, walking through an example where users select a list of columns and each one gets a card with a close button. In Shiny, that escalates two ways. Every new card needs a nested observeEvent() created inside the parent observer that spawned it, wired to a string ID generated on the fly. And when the card goes away, if you don’t manually destroy the observer you dynamically created, it keeps firing as a ghost, with stale inputs lingering in server memory.
In irid, the same thing is a function and a reactiveVal, and the framework handles the rest:
library(irid)
Card <- function(col, on_close) {
tags$div(
class = "card",
tags$strong(col),
tags$button(onClick = on_close, "\u00d7")
)
}
App <- function() {
selected_columns <- reactiveVal(character(0))
tags$div(
# ...add-back UI...
Each(selected_columns, \(col) {
Card(
col,
on_close = \() selected_columns(setdiff(selected_columns(), col))
)
})
)
}
iridApp(App)
The live demo fills in the # add-back UI with a dataset selector and column dropdown:
Card doesn’t know about the list — just takes a column name and a close callback. The parent owns selected_columns and iterates with Each(), which mounts a card when an item is added and tears it down when one is removed.
The onClick handler lives inside the card, so when the card unmounts it goes with it — no nested observeEvent() to create, no string ID to generate, nothing to wire up by hand. Reactive attributes and nested control flow get the same treatment: mounted with the component, torn down with it.
Conditional rendering works the same way: When() and Match() mount their active branch and destroy the inactive one — no renderUI() regenerating a block just to toggle a label.
As a bonus, here’s a todo list example that uses the same pattern.
Try It Out
irid can be used in two ways: iridApp() for new projects or full migrations, or iridOutput() / renderIrid() to embed components into an existing Shiny app. With the embedded path, you don’t have to do it all at once — start with the places where Shiny’s complexity wall hits hardest, and grow from there.
Heads up: I think the core API is stable, but no guarantees. The surface is small by design — next step is adding reactive “stores” like Solid.js. But what’s there already handles everything shown in this post.
I’m releasing it now because feedback from people actually building with it is how it matures. If you hit a bug or want a feature, please open an issue. I’ll be actively working through them.
If you’ve felt the pain at the top of this post, give irid a try. I think you’ll find that the component model is what Shiny’s reactive engine was waiting for.