irid is a new way to build dynamic Shiny apps. Instead of
renderUI, irid binds reactivity directly to individual DOM
attributes — one reactive changes, one attribute updates. You keep using
reactiveVal and reactive as usual, and build
UIs from plain R functions that compose naturally.
The core idea is one simple rule: pass a function instead of a value to make any tag attribute reactive.
Your first irid app
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)A irid app is a single function that returns a tag tree. Inside the
function you create reactiveVals for state and wire them to
the DOM with functions. The function runs once — irid
processes the tag tree, extracts the reactive functions, and sets up
observers that surgically update individual DOM nodes when values
change. No re-rendering entire subtrees, no DOM destruction.
Three things to notice: count appears as a child of
tags$p() and its value is displayed inline; the button’s
disabled attribute is a function, so it re-evaluates
whenever count changes; and onClick is wired
directly on the tag — no observers, no input/output IDs, no
updateActionButton() or observeEvent().
iridApp() returns a shinyApp object, so it
works with runApp(), shinytest2, and
deployment tools.
Components compose
Because a component is just a function that returns a tag tree, you
build larger UIs by calling smaller ones. Pass reactiveVals
as arguments to share state between them:
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)
total <- reactive(count_a() + count_b())
page_fluid(
tags$h3(class = "text-center", \() paste("Total:", total())),
layout_columns(
Counter("A", count_a),
Counter("B", count_b)
)
)
}
iridApp(App)count_a and count_b are created once in
App and passed down to each Counter. There are
no string IDs to keep in sync. total is a derived reactive
that reads both — any component in the tree can read or write shared
state by holding a reference to the same reactiveVal.
Reactive attributes
Any tag attribute can be static or reactive. Pass a function to make it reactive:
# Static
tags$div(class = "panel")
# Reactive
tags$div(class = \() if (is_active()) "panel active" else "panel")Since reactiveVal and reactive are both
functions, they work directly as attribute values:
name <- reactiveVal("hello")
upper_name <- reactive(toupper(name()))
tags$span(upper_name) # reactive is a function
tags$span(name) # reactiveVal is a function
tags$span(\() name()) # anonymous function works tooReactive children
Tag children can also be reactive functions, but they must return text only — not tag trees. Use control flow primitives (below) for structural changes:
tags$span(\() paste("Count:", count())) # text — worksEvent callbacks
Event callbacks receive (event) or
(event, id). The event is a list of
primitive-valued properties from the browser event, plus element
properties like value, valueAsNumber, and
checked:
onInput = \(event) threshold(event$valueAsNumber) # event object
onClick = \(event, id) handle_click(id) # event + element id
onClick = \() count(count() + 1) # neitherBare onInput callbacks are automatically debounced (200
ms). All other events fire immediately. Override with explicit
wrappers:
onInput = event_immediate(\(event) name(event$value))
onInput = event_throttle(\(event) threshold(event$valueAsNumber), 100)
onInput = event_debounce(\(event) name(event$value), 500)Controlled inputs
Inputs are controlled by binding their value attribute
to a reactiveVal. The reactive is the single source of
truth — setting it from anywhere updates every input bound to it:
name <- reactiveVal("")
tags$input(
type = "text",
value = name,
onInput = \(event) name(event$value)
)Multiple inputs can share the same reactiveVal. Type in
one, the others update. No updateTextInput. No
freezeReactiveValue.
See the Temperature Converter example.
Control flow
Because the component function runs once, you can’t use plain
if/else for conditional rendering. irid
provides control flow primitives instead.
When
When(logged_in,
Dashboard(),
otherwise = LoginPanel()
)When the condition changes, the old content is torn down and the new content is mounted.
Each
Dynamic lists. The callback receives each item as a plain value — when the list changes, items are diffed by key and DOM nodes are reused where possible:
tags$ul(
Each(items, \(item) {
tags$li(item$name)
})
)Index
Like Each, but keyed by position. The callback receives
each item as a reactive accessor (item() to read). When
values change without a length change, existing observers re-fire
without DOM recreation:
tags$ul(
Index(items, \(item) {
tags$li(\() item()$name)
})
)Use Each when items have a stable identity (todos,
records). Use Index when you care about positions
(rankings, slots).
See the Todo List example.
Shiny outputs
Plots, tables, and other binary outputs use Shiny’s existing render
infrastructure via Output, or convenience wrappers:
PlotOutput(\() ggplot(mtcars, aes(wt, mpg)) + geom_point())
TableOutput(\() head(mtcars))
DTOutput(\() mtcars)For any render/output pair, pass both functions explicitly:
Output(renderPlot, plotOutput, \() ggplot(mtcars, aes(wt, mpg)) + geom_point())Incremental adoption
You don’t have to go all-in. Drop irid into an existing Shiny app
with iridOutput / renderIrid:
ui <- fluidPage(
sliderInput("n", "N", 1, 100, 50),
iridOutput("filters"),
plotOutput("plot")
)
server <- function(input, output, session) {
threshold <- reactiveVal(0.5)
output$filters <- renderIrid(
tags$div(
tags$input(
type = "range", min = 0, max = 1, step = 0.1,
value = threshold,
onInput = \(event) threshold(event$valueAsNumber)
),
tags$span(\() paste("Threshold:", threshold()))
)
)
output$plot <- renderPlot({
mtcars |> head(input$n) |>
dplyr::filter(mpg > threshold() * 30) |>
ggplot2::ggplot(ggplot2::aes(wt, mpg)) + ggplot2::geom_point()
})
}
shinyApp(ui, server)The migration path:
- Start with a normal Shiny app
- Drop in one
iridOutput/renderIridfor a painfulrenderUI - Gradually convert more components
- Eventually switch to
iridAppwhen the whole app is irid - Old
sliderInputetc. still work at every stage
See the Shiny Modules example.
