# Getting Started with irid

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 `reactiveVal`s 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 `reactiveVal`s 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.