--- title: "Getting Started with irid" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Getting Started with irid} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` 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: ```r # install.packages("pak") pak::pak("khusmann/irid") ``` ## Your first irid app ```r 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.](https://irid.kylehusmann.com/apps/counter/index.html?_shinylive-mode=editor-terminal-viewer){target="_blank"}*** 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: ```r 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.](https://irid.kylehusmann.com/apps/composing/index.html?_shinylive-mode=editor-terminal-viewer){target="_blank"}*** `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: ```r # 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: ```r 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: ```r 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`: ```r 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()`: ```r 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: ```r 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: ```r 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`: ```r # 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](https://irid.kylehusmann.com/apps/temperature/index.html?_shinylive-mode=editor-terminal-viewer){target="_blank"} 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: ```r 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: ```r 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: ```r 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](https://irid.kylehusmann.com/apps/todo/index.html?_shinylive-mode=editor-terminal-viewer){target="_blank"} example.* ## Shiny outputs Plots, tables, and other binary outputs use Shiny's existing render infrastructure via `Output`, or convenience wrappers: ```r PlotOutput(\() ggplot(mtcars, aes(wt, mpg)) + geom_point()) TableOutput(\() head(mtcars)) DTOutput(\() mtcars) ``` For any render/output pair, pass both functions explicitly: ```r 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`: ```r 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](https://irid.kylehusmann.com/apps/shiny-interop/index.html?_shinylive-mode=editor-terminal-viewer){target="_blank"} example.*