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 = reactiveProxy(get = count, set = \(v) count(as.numeric(v)))
),
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:
onClick = \(event) handle_click(event) # event object
onClick = \(event, id) handle_click(id) # event + element id
onClick = \() count(count() + 1) # neitherEvent timing and event.preventDefault() live on the
element, not the handler. Use .event to
set the timing for every event on the element, and
.prevent_default = TRUE to call preventDefault
before dispatch:
tags$input(value = field, .event = event_debounce(500))
tags$button("Save", onClick = \() save(), .event = event_throttle(1000))
tags$form(onSubmit = \(e) handle(e), .prevent_default = TRUE).event and .prevent_default both accept a
named list keyed by lowercase DOM event name to override per event.
Unmapped events fall back to the per-event timing default and
FALSE respectively:
tags$input(
value = field,
onKeyDown = \(e) if (e$key == "Enter") submit(),
.event = list(input = event_debounce(500))
)
tags$form(
onSubmit = \() submit(),
onClick = \(e) handle_click(e),
.prevent_default = list(submit = TRUE)
)When .event is omitted, irid applies a per-event default
keyed on the DOM event name: input events default to
event_debounce(200) (typing floods the wire with
intermediate values), every other event to
event_immediate(). The rule is the same for auto-bind
synthetic events and explicit on* handlers — adding
value = rv to an existing onInput doesn’t
change its timing.
Controlled inputs
Bind a state-binding prop (value, checked)
to a callable to get two-way binding for free. The callable can be a
reactiveVal, a store leaf, a reactiveProxy, or
any function:
name <- reactiveVal("")
tags$input(type = "text", value = name)The read fills the input; DOM events on the element write back
through the same callable. Multiple inputs can share the same
reactiveVal — type in one, the others update. No
updateTextInput. No freezeReactiveValue. No
onInput write handler.
If you need to transform reads or gate writes, wrap the callable in a
reactiveProxy:
# Coerce the string from a range slider into a number on write
reactiveProxy(get = count, set = \(v) count(as.numeric(v)))
# Bidirectional transform — display Fahrenheit, store Celsius
reactiveProxy(
get = \() celsius() * 9/5 + 32,
set = \(f) celsius((as.numeric(f) - 32) * 5/9)
)
# Read-only view — writes silently dropped, input snaps back to current value
reactiveProxy(get = name)A 0-arg function (e.g. \() toupper(name())) behaves the
same as reactiveProxy(get = name) (read-only with
snap-back).
If you combine an auto-bound state-binding prop with an explicit
on* handler for the same DOM event
(e.g. value = rv, onInput = \(e) ...), the auto-bind write
always runs first. Your handler observes the updated state regardless of
attribute order.
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 is the binary specialization. The bodies are 0-arg
functions that return a tag tree — they are called fresh on each
activation, since the previous branch’s closures are torn down with its
reactives:
When(logged_in,
\() Dashboard(),
otherwise = \() LoginPanel()
)Match / Case / Default
Match dispatches on a leading callable. Records are
projected as a mini-store for the active case body; scalars are passed
as the bare callable. Case’s first arg is one of: a
function \(v) cond of the bound value, a function
\() cond ignoring it (cross-cutting), or a literal
(equality match via identical). Case’s second
arg and Default’s arg are 0- or 1-arg functions returning a
tag tree:
Each
Dynamic lists. The callback receives a per-item callable — a mini-store for record items, a scalar accessor for atomic items — and an optional 1-indexed position accessor:
tags$ul(
Each(todos, by = \(t) t$id, \(todo) {
tags$li(
tags$input(type = "checkbox", checked = todo$done),
tags$span(\() todo$text())
)
})
)by = NULL (the default) reconciles positionally — slot
i is slot i, the list grows or shrinks at the end, and
same-length value changes update slots in place without DOM recreation.
by = fn keys items by fn(item) — kept items
are reused across reorders, adds, and removes; mini-store leaves are
diffed so only changed fields fire.
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 = reactiveProxy(get = threshold, set = \(v) threshold(as.numeric(v)))
),
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.
