Skip to contents

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.

Installation

Install from GitHub:

# install.packages("pak")
pak::pak("khusmann/irid")

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)

Try it live.

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)

Try it live.

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 too

Reactive 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 — works

Event 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)                    # neither

Event 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().

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).

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.

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:

Match(tab,
  Case("home",     \() HomePage()),
  Case("settings", \() SettingsPage()),
  Default(\() NotFoundPage())
)

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:

  1. Start with a normal Shiny app
  2. Drop in one iridOutput/renderIrid for a painful renderUI
  3. Gradually convert more components
  4. Eventually switch to iridApp when the whole app is irid
  5. Old sliderInput etc. still work at every stage

See the Shiny Modules example.