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.
Install from GitHub:
# install.packages("pak")
pak::pak("khusmann/irid")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.
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.
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 tooTag 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 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, backpressure, and event.preventDefault()
ride the slot they configure. Wrap a handler (or a
bound reactive) in wire() to attach a timing shape —
wire_immediate(), wire_debounce(ms), or
wire_throttle(ms) — and DOM listener options via
wire_dom_opts():
tags$input(value = wire(field, wire_debounce(500)))
tags$button("Save", onClick = wire(\() save(), wire_throttle(1000)))
tags$form(onSubmit = wire(\(e) handle(e),
dom_opts = wire_dom_opts(prevent_default = TRUE)))Each event is configured exactly once, on its own slot — there are no element-level lists to keep in sync:
tags$input(
value = wire(field, wire_debounce(500)),
onKeyDown = \(e) if (e$key == "Enter") submit()
)
tags$form(
onSubmit = wire(\() submit(),
dom_opts = wire_dom_opts(prevent_default = TRUE)),
onClick = \(e) handle_click(e)
)A bare handler (onClick = \() …) is sugar for
wire(\() …) with the default config. When a wire carries no
timing, irid applies a per-event default keyed on the DOM
event name: input events default to
wire_debounce(200) (typing floods the wire with
intermediate values), every other event to
wire_immediate(). coalesce (gate on server
idle) is universal, so it lives on the carrier —
wire(field, wire_debounce(500), coalesce = FALSE) — and
defaults to TRUE for rate-limited shapes,
FALSE for wire_immediate().
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).
A DOM event is bound or handled, never
both. Combining an auto-bound state-binding prop with an
explicit on* handler for the same event
(e.g. value = rv, onInput = \(e) ...) is an error — the
binding already claims that event. This is per-event:
value = rv with onKeyDown is fine, since
they’re different events. To run a synchronous side-effect or validation
on write, put it in the proxy’s set
(value = reactiveProxy(get, set)); to react asynchronously,
observe the bound reactive.
See the Temperature Converter example.
Because the component function runs once, you can’t use plain
if/else for conditional rendering. irid
provides control flow primitives instead.
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 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:
Match(tab,
Case("home", \() HomePage()),
Case("settings", \() SettingsPage()),
Default(\() NotFoundPage())
)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.
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())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:
iridOutput/renderIrid for a
painful renderUIiridApp when the whole app is
iridsliderInput etc. still work at every stageSee the Shiny Modules example.