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 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:

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.