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

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:

onInput = \(event) threshold(event$valueAsNumber)  # event object
onClick = \(event, id) handle_click(id)             # event + element id
onClick = \() count(count() + 1)                    # neither

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

Match / Case / Default

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

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:

  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.